mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-01 22:37:10 -06:00
Compare commits
311
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb49ac055d | ||
|
|
f2c32842bc | ||
|
|
7b0e079705 | ||
|
|
6a2b520fa4 | ||
|
|
49c2f61373 | ||
|
|
2c99a42780 | ||
|
|
ab0eccd254 | ||
|
|
0377edabd8 | ||
|
|
438775cea1 | ||
|
|
b94821ea16 | ||
|
|
e71c9ce31d | ||
|
|
bbf07875d8 | ||
|
|
9752ca5897 | ||
|
|
e8282d957a | ||
|
|
f93c0cbe52 | ||
|
|
73af86b1bf | ||
|
|
503a533e65 | ||
|
|
27068500dd | ||
|
|
16f6145cd3 | ||
|
|
7d512a6905 | ||
|
|
54ec19ddf8 | ||
|
|
d805dd94f1 | ||
|
|
6dca584bbd | ||
|
|
b9c074a543 | ||
|
|
e900513104 | ||
|
|
ecdc90cdc4 | ||
|
|
15090b9d81 | ||
|
|
24986cdb23 | ||
|
|
5632755be4 | ||
|
|
f93fe6053f | ||
|
|
6a1e0d0002 | ||
|
|
645cbe52d4 | ||
|
|
4576b8e624 | ||
|
|
5e7790ea85 | ||
|
|
8634995c20 | ||
|
|
a44e3ec8f9 | ||
|
|
4029a4e6fc | ||
|
|
e9dd252096 | ||
|
|
8bc825825f | ||
|
|
282ebedc85 | ||
|
|
a95a570edc | ||
|
|
45c4197bc1 | ||
|
|
3c25061bab | ||
|
|
328a58dd03 | ||
|
|
7583871d45 | ||
|
|
ed1296a1d4 | ||
|
|
e216e7afc1 | ||
|
|
b654049545 | ||
|
|
ddda3c4670 | ||
|
|
b094b29958 | ||
|
|
4eda77a2de | ||
|
|
adf33b6ba7 | ||
|
|
66601038df | ||
|
|
4fd9075e99 | ||
|
|
4bb3679e41 | ||
|
|
ee7e98eafb | ||
|
|
ba27ef13fb | ||
|
|
c0627f2b0b | ||
|
|
ebf27211cf | ||
|
|
ced8576f4c | ||
|
|
7740ea650e | ||
|
|
8450531b0e | ||
|
|
25a087db43 | ||
|
|
0ecfbb57f9 | ||
|
|
6689d186f6 | ||
|
|
def2b9bd31 | ||
|
|
bec112a4b8 | ||
|
|
26187afac2 | ||
|
|
72cbbe096e | ||
|
|
722a6ab1c0 | ||
|
|
f235208630 | ||
|
|
3a1c91f2d4 | ||
|
|
acfc3d5041 | ||
|
|
01a75b99b1 | ||
|
|
364688a8c3 | ||
|
|
5819b1ca62 | ||
|
|
c14e18cfa8 | ||
|
|
eb2f0dab46 | ||
|
|
6dc062d132 | ||
|
|
a1f8be2a49 | ||
|
|
60a64acec3 | ||
|
|
722afa4201 | ||
|
|
1955793aa6 | ||
|
|
639964b0ed | ||
|
|
4c5930dac1 | ||
|
|
12afc75411 | ||
|
|
2337ff75fa | ||
|
|
2d9bcb6b1b | ||
|
|
b059da2d4e | ||
|
|
57e9e0f244 | ||
|
|
87d3ea5363 | ||
|
|
68d45318bb | ||
|
|
d69c386f8f | ||
|
|
e541a6f62f | ||
|
|
bbe1dad1e7 | ||
|
|
0f1a127e63 | ||
|
|
c7148d6ba9 | ||
|
|
bcce02ab36 | ||
|
|
b246ea166a | ||
|
|
ce7e4ea28e | ||
|
|
6c6326486f | ||
|
|
5155d2c18c | ||
|
|
3ee1978910 | ||
|
|
3cffe22c00 | ||
|
|
1ae38ec2be | ||
|
|
0f9b926497 | ||
|
|
32e4900bc5 | ||
|
|
ed78539488 | ||
|
|
b578be0459 | ||
|
|
96579e6b4e | ||
|
|
8efba59b35 | ||
|
|
4963e30eed | ||
|
|
22df7caf4d | ||
|
|
124243b1be | ||
|
|
2c4f4669c6 | ||
|
|
41de47398a | ||
|
|
582f411876 | ||
|
|
d8501a5aeb | ||
|
|
9bee82220e | ||
|
|
79e3610cbb | ||
|
|
faa8221bca | ||
|
|
e9fe3d29b6 | ||
|
|
f6398e29a9 | ||
|
|
42c254c87a | ||
|
|
89a64d1ef5 | ||
|
|
d81b43b336 | ||
|
|
fd53f15332 | ||
|
|
d41b8b48ca | ||
|
|
00ba3d4198 | ||
|
|
91c030bc9f | ||
|
|
b7403f41ad | ||
|
|
77402c4e7e | ||
|
|
1225f56187 | ||
|
|
f4c9c89c64 | ||
|
|
0aae9d05db | ||
|
|
c98efffc06 | ||
|
|
2a647661fb | ||
|
|
05ee2e0126 | ||
|
|
0d5a149093 | ||
|
|
f82319deb7 | ||
|
|
af9d5c9541 | ||
|
|
d7530233cc | ||
|
|
1fa7383264 | ||
|
|
17e648bde7 | ||
|
|
ddb67eaa45 | ||
|
|
74dbec4107 | ||
|
|
0674573b45 | ||
|
|
ee96c4c99d | ||
|
|
342855315e | ||
|
|
5a07c46534 | ||
|
|
897add99a8 | ||
|
|
629711582f | ||
|
|
4d027de402 | ||
|
|
7d31c01f6f | ||
|
|
6c20b3e609 | ||
|
|
c1eb02f23c | ||
|
|
7f203bc1c8 | ||
|
|
6bcd997be8 | ||
|
|
5a5aa591cd | ||
|
|
6a66004a06 | ||
|
|
5cd68ee632 | ||
|
|
c187515ba1 | ||
|
|
2cff091c1a | ||
|
|
e4d2dafa9d | ||
|
|
45556583a5 | ||
|
|
d785c05c53 | ||
|
|
bf2a379ec6 | ||
|
|
e251c960d9 | ||
|
|
65f3c3418f | ||
|
|
7ee101a5b9 | ||
|
|
a9cc06dc0f | ||
|
|
d8d75e4473 | ||
|
|
ab26f22c4a | ||
|
|
020194ebdb | ||
|
|
90efee7636 | ||
|
|
58898d5168 | ||
|
|
17d19b42c5 | ||
|
|
bd3d260837 | ||
|
|
3aadd0a4af | ||
|
|
dd6585f166 | ||
|
|
bbeb7e2253 | ||
|
|
a58a7618be | ||
|
|
b2f1197f84 | ||
|
|
0ecdf2ff2d | ||
|
|
878bac84ba | ||
|
|
36e43fa672 | ||
|
|
dc3a999081 | ||
|
|
e73ce9c274 | ||
|
|
6d3370635d | ||
|
|
b729cf0cb4 | ||
|
|
68407a34cd | ||
|
|
a1aa17fc14 | ||
|
|
5ee71b4862 | ||
|
|
a029d8aed1 | ||
|
|
ee03feb81d | ||
|
|
e5cffe7641 | ||
|
|
c1484abe80 | ||
|
|
49600e0344 | ||
|
|
7ef2633a7e | ||
|
|
57ba4b9ed8 | ||
|
|
62736d93df | ||
|
|
f01a107a40 | ||
|
|
dd2e2c8605 | ||
|
|
e3d93581b7 | ||
|
|
e6954843bc | ||
|
|
4148fb1171 | ||
|
|
67ffe00b32 | ||
|
|
a91e1b5ea4 | ||
|
|
b39c1ba1a3 | ||
|
|
04121fb020 | ||
|
|
5dd5e8b31b | ||
|
|
4095cc3413 | ||
|
|
6b97197f69 | ||
|
|
ea541fe66d | ||
|
|
fe9f6dbcc9 | ||
|
|
ea5780d2ab | ||
|
|
42477bd806 | ||
|
|
49731f3faa | ||
|
|
1b2f7a8c09 | ||
|
|
40a7fe6be2 | ||
|
|
1489161611 | ||
|
|
bf70f48bbd | ||
|
|
d5f23907dc | ||
|
|
f7c49cb0f8 | ||
|
|
0cb2af4f4b | ||
|
|
50a1c34647 | ||
|
|
b638caa2d2 | ||
|
|
85db9cfef5 | ||
|
|
d1db090a7c | ||
|
|
4bf785c4cf | ||
|
|
a5526724aa | ||
|
|
0696c55698 | ||
|
|
b37a732455 | ||
|
|
b0b7e6b5ea | ||
|
|
1b0faddfa8 | ||
|
|
0d2d1776dd | ||
|
|
14f0eec255 | ||
|
|
5b46f9ba4d | ||
|
|
7563ebb314 | ||
|
|
8c366eb176 | ||
|
|
77200f9c13 | ||
|
|
9003e2b0af | ||
|
|
84c2d37a49 | ||
|
|
66e5c06b25 | ||
|
|
f490f9da9a | ||
|
|
52d81d02d3 | ||
|
|
f199efde84 | ||
|
|
5d428e408f | ||
|
|
f3961d74d9 | ||
|
|
823ce76e39 | ||
|
|
944c67ba89 | ||
|
|
500f9cdfea | ||
|
|
65a0dc8dc6 | ||
|
|
0b007fd47b | ||
|
|
0bdd2a3e04 | ||
|
|
a605fa5ca7 | ||
|
|
9129d713ca | ||
|
|
f501f7cb5f | ||
|
|
4f071a5b88 | ||
|
|
de8a85dc75 | ||
|
|
7f236c3ec5 | ||
|
|
01e32ff863 | ||
|
|
9fca3bae3a | ||
|
|
c4974d9e8d | ||
|
|
401d811fa0 | ||
|
|
f06a1d3ba5 | ||
|
|
6fac5994f7 | ||
|
|
8e0353ae41 | ||
|
|
4c246f082a | ||
|
|
86b44280ac | ||
|
|
3db25e4aa9 | ||
|
|
fd071202e7 | ||
|
|
753c624a7d | ||
|
|
6f7da7345f | ||
|
|
00856d7178 | ||
|
|
3d944e735a | ||
|
|
6766118d05 | ||
|
|
6b23f3b368 | ||
|
|
3a8ff6b6bc | ||
|
|
8a18026a13 | ||
|
|
4f5e596c71 | ||
|
|
c7dfd92ff1 | ||
|
|
66e92e3698 | ||
|
|
6142567b6a | ||
|
|
02fffea7c9 | ||
|
|
8ce3eb58c7 | ||
|
|
3d51cd3db2 | ||
|
|
08da5c5433 | ||
|
|
358614e1ff | ||
|
|
f2c21cd62c | ||
|
|
a6d5820e16 | ||
|
|
338b5d6360 | ||
|
|
6c595dfd16 | ||
|
|
3a6f3fbb17 | ||
|
|
9feaa3d480 | ||
|
|
4ca965d990 | ||
|
|
3963cf3012 | ||
|
|
3d0fcaccbf | ||
|
|
4480fd50d9 | ||
|
|
a8e39fec53 | ||
|
|
faeb272559 | ||
|
|
3693759149 | ||
|
|
a2ca57ca3f | ||
|
|
08028c44c0 | ||
|
|
023cda9554 | ||
|
|
080b05a0fd | ||
|
|
0d06d94867 | ||
|
|
e4804235c5 | ||
|
|
0802788da9 | ||
|
|
af9b00dd4c | ||
|
|
0dd5b56dfe |
@@ -21,6 +21,8 @@ jobs:
|
||||
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
# Same craft-fonts commit as release.yml.
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
packages: >-
|
||||
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcursor-dev libxrandr-dev libxi-dev
|
||||
libgtk-3-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
|
||||
|
||||
@@ -27,3 +27,5 @@ jobs:
|
||||
freebsd:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
components: rustfmt, clippy
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ jobs:
|
||||
with:
|
||||
app-name: wordcraft
|
||||
app-title: WordCraft
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
web-craft-fonts: true
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
|
||||
@@ -8,6 +8,8 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
|
||||
| Asset | Author | Source | Licence | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `egui` dependency: `epaint_default_fonts/fonts/egui-icons.ttf` | John Slegers (emoji-icon-font) | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/egui-icons.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full copyright and permission text in NOTICE) | Small egui special-icon subset; embedded by default_fonts, retained in FontDefinitions::default() |
|
||||
| `egui` dependency: `epaint_default_fonts/fonts/emoji-icon-font.ttf` | John Slegers | https://github.com/storytold/egui/blob/173cc7375d35dc5fbc5b5d0994fdf787f547c5d1/crates/epaint_default_fonts/fonts/emoji-icon-font-mit-license.txt; original https://github.com/jslegers/emoji-icon-font | MIT (full copyright and permission text in NOTICE) | Parent monochrome icon/emoji face embedded by monochrome_emoji_fonts; separate from the egui-icons subset |
|
||||
| `assets/hyphenation/en-us.pat` | DesignCraft contributors (generated) | trained from the Moby list by DesignCraft's patgen | Public Domain (CC0-1.0) | Our own Liang hyphenation patterns (no TeX patterns) |
|
||||
| `assets/hyphenation/en-us.dic` | Grady Ward (Moby Hyphenator II word list); converted by DesignCraft contributors | https://www.gutenberg.org/ebooks/3204 (file `mhyph.txt`, SHA-256 `eeb30474c86b8af3469035ec1a0913e35905325ca885290db9dfed9881e230ac`), via DesignCraft | Public Domain | ~165k words with break points; drives spelling and hyphenation |
|
||||
| `assets/spelling/en-extra.txt` | WordCraft contributors | original word list written for this repository (common modern words the Moby list lacks); no other dictionary copied | MIT OR Apache-2.0 | ~240 lowercase English base forms supplementing spelling (#520), including British spellings (#40) |
|
||||
|
||||
Generated
+481
-313
File diff suppressed because it is too large
Load Diff
+34
-9
@@ -52,13 +52,18 @@ harfrust = "0.13"
|
||||
# WOFF / WOFF2 web fonts added by the user (#582): pure Rust (MIT); its brotli and zlib decoders
|
||||
# (brotli-decompressor, flate2) are already in the tree.
|
||||
wuff = "0.2.9"
|
||||
# WOFF2's brotli stream, decoded ahead of wuff so its glyph point counts are checked first (#3703).
|
||||
# The same crate and version wuff already decodes it with (no new code in the build): pure Rust,
|
||||
# `forbid(unsafe_code)` in its default build, no_std-capable (builds for wasm), BSD-3-Clause/MIT.
|
||||
brotli-decompressor = "5"
|
||||
vello_cpu = "0.2"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756", version = "=0.1.0", default-features = false, features = ["write"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", version = "=0.1.0", default-features = false, features = ["write"] }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", features = ["egui"] }
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
# zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates.
|
||||
miniz_oxide = "0.8"
|
||||
miniz_oxide = "0.9"
|
||||
cfb = "0.15"
|
||||
# Legacy text encodings (GBK/GB18030, Big5, Shift_JIS, EUC-KR, single-byte code pages) for plain
|
||||
# text and CSV recipient lists (#778): encoding_rs ((Apache-2.0 OR MIT) AND BSD-3-Clause) and its
|
||||
@@ -70,17 +75,22 @@ aes = "0.9"
|
||||
sha1 = "0.11"
|
||||
sha2 = "0.11"
|
||||
hmac = "0.13"
|
||||
base64 = "0.22"
|
||||
base64 = "0.23"
|
||||
getrandom = "0.3"
|
||||
zeroize = "1"
|
||||
# Unix open flags (O_NONBLOCK for the engine's checked file reads, #3666); already in the lock as a
|
||||
# dependency of other crates.
|
||||
libc = "0.2"
|
||||
|
||||
# ui
|
||||
egui = "0.36"
|
||||
egui_extras = { version = "0.36", default-features = false }
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
egui = "0.37"
|
||||
egui_extras = { version = "0.37", default-features = false }
|
||||
eframe = { version = "0.37", default-features = false }
|
||||
egui_kittest = "0.37"
|
||||
# A monotonic clock that works on native and in the browser (`performance.now()`).
|
||||
web-time = "1.1"
|
||||
rfd = "0.17"
|
||||
arboard = { version = "3.6", default-features = false, features = ["wayland-data-control"] }
|
||||
arboard = { version = "3.7", default-features = false, features = ["wayland-data-control"] }
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
@@ -110,4 +120,19 @@ debug = 0
|
||||
|
||||
# Required by craft-pdf: the backend rejects writers that failed during unwinding.
|
||||
[patch.crates-io]
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" }
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
|
||||
|
||||
@@ -95,14 +95,32 @@ list (author, source, license) is in ATTRIBUTION.md.
|
||||
FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
- All UI icons are drawn in code (crates/ui-egui/src/icons.rs) and are original work, MIT OR Apache-2.0.
|
||||
- Application-drawn UI icons (crates/ui-egui/src/icons.rs) are original work, MIT OR Apache-2.0;
|
||||
the bundled egui font icons have the separate notice below.
|
||||
|
||||
Rust dependencies (egui, eframe, vello_cpu, skrifa, harfrust, krilla, quick-xml, zip, image, …)
|
||||
are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io or pinned Git sources.
|
||||
The craft-pdf writer and Krilla 0.8.2 backend are pinned to storytold/craft-libs revision
|
||||
c3487320b2d1de657b860b3c8abb44e1f2b32bd8. The backend retains its MIT OR Apache-2.0 licensing
|
||||
b926b5c03e256c86ca10934f58d03f628810b5b7. The backend retains its MIT OR Apache-2.0 licensing
|
||||
and upstream bundled material; see that revision's licenses/pdf-writer/ and ATTRIBUTION.md.
|
||||
The backend patch prevents finalization during an unwind and rejects a writer after failed
|
||||
cleanup. WordCraft enables only writing; PDF import and its image decoders are not enabled.
|
||||
|
||||
No Microsoft, Adobe, Avid or Autodesk icons, images, templates or artwork are included.
|
||||
|
||||
Bundled egui font icons
|
||||
|
||||
The canonical egui0.37 dependency includes egui-icons.ttf (a small subset of emoji-icon-font)
|
||||
through default_fonts, and emoji-icon-font.ttf through monochrome_emoji_fonts. Both are embedded
|
||||
by the UI font definitions. Their upstream project is https://github.com/jslegers/emoji-icon-font;
|
||||
the exact copyright and permission notice from the pinned egui dependency follows.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2014 John Slegers
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
@@ -221,13 +221,16 @@ closer pagination fidelity, a stability sweep and native printing. Details and e
|
||||
|
||||
### Windows
|
||||
|
||||
| Build | Installer | Portable |
|
||||
|---|---|---|
|
||||
| x64 (64-bit Intel/AMD) | `wordcraft-<ver>-windows-x64.msi` | `wordcraft-<ver>-windows-x64-portable.zip` |
|
||||
| arm64 (Snapdragon and other ARM PCs) | `wordcraft-<ver>-windows-arm64.msi` | `wordcraft-<ver>-windows-arm64-portable.zip` |
|
||||
| x86 (32-bit) | `wordcraft-<ver>-windows-x86.msi` | `wordcraft-<ver>-windows-x86-portable.zip` |
|
||||
| Build | Installer (all users) | Installer (just me) | Portable |
|
||||
|---|---|---|---|
|
||||
| x64 (64-bit Intel/AMD) | `wordcraft-<ver>-windows-x64.msi` | `wordcraft-<ver>-windows-user-x64.msi` | `wordcraft-<ver>-windows-x64-portable.zip` |
|
||||
| arm64 (Snapdragon and other ARM PCs) | `wordcraft-<ver>-windows-arm64.msi` | `wordcraft-<ver>-windows-user-arm64.msi` | `wordcraft-<ver>-windows-arm64-portable.zip` |
|
||||
| x86 (32-bit) | `wordcraft-<ver>-windows-x86.msi` | `wordcraft-<ver>-windows-user-x86.msi` | `wordcraft-<ver>-windows-x86-portable.zip` |
|
||||
|
||||
Installers and executables are code-signed.
|
||||
The all-users installer puts WordCraft in Program Files and needs administrator rights. The
|
||||
just-me installer needs none: it installs into `%LOCALAPPDATA%\Programs\WordCraft` for your
|
||||
account only. Choose one: installing both gives two WordCraft entries in Settings › Apps and two
|
||||
in the Start menu. Installers and executables are code-signed.
|
||||
|
||||
**If the app doesn't open on Windows:** the desktop app initializes only DirectX 12 by default.
|
||||
Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's
|
||||
|
||||
+29
-1
File diff suppressed because one or more lines are too long
@@ -36,7 +36,11 @@ USAGE:
|
||||
{\"file\"?, \"template\"?, \"commands\": [{\"command\": id,
|
||||
\"params\"?: {…}} | id, …], \"save\"?: [OUT, …]}; save
|
||||
formats follow the extension (docx and pdf in one run);
|
||||
relative paths resolve against the script's folder
|
||||
relative paths resolve against the script's folder.
|
||||
file.new and file.open open another document and
|
||||
make it active; later commands and the saves work on
|
||||
the active one; view.switchWindows lists the open
|
||||
documents and with {\"id\": N} makes one active
|
||||
wordcraft-cli commands [--json] list every command
|
||||
wordcraft-cli parity [--markdown] feature-catalog parity
|
||||
wordcraft-cli mcp [--connect HOST:PORT | --author NAME] [--automation-read-root DIR] [--automation-write-root DIR]
|
||||
@@ -387,6 +391,8 @@ fn valid_connect_addr(addr: &str) -> bool {
|
||||
!host.is_empty() && port.parse::<u16>().is_ok()
|
||||
}
|
||||
|
||||
/// Run the subcommand named by `args` (the command line after the program name). Files named
|
||||
/// there are opened by their real names, which may not be valid Unicode.
|
||||
fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
let script_path = script_operand(args);
|
||||
let (args, file) = text_args(args)?;
|
||||
@@ -482,10 +488,14 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
let mut s = match (from_file, from_template) {
|
||||
(Some(f), _) => open(&f, password.as_deref())?,
|
||||
// Both go through `file.new`, which puts a blank document on the region's paper (#3152).
|
||||
// `file.new` opens beside the session's first document, which is then closed: the
|
||||
// run starts with one open document.
|
||||
(None, t) => {
|
||||
let mut s = session(wordcraft_doc::Document::new());
|
||||
let first = s.document_id();
|
||||
let params = t.map_or_else(|| json!({}), |t| json!({"template": t}));
|
||||
s.run("file.new", ¶ms).map_err(|e| e.to_string())?;
|
||||
s.close_document(first).map_err(|e| e.to_string())?;
|
||||
s
|
||||
}
|
||||
};
|
||||
@@ -657,14 +667,14 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
let out = wordcraft_zotero::client::run_command(&opts, command, &mut |call| {
|
||||
let r = bridge.handle(&mut s, &mut host, call);
|
||||
if trace {
|
||||
eprintln!("← {} {}", call.method, clip(&Value::Array(call.args.clone()).to_string()));
|
||||
eprintln!("→ {}", clip(&String::from_utf8_lossy(&wordcraft_zotero::wire::encode_reply(&r))));
|
||||
eprintln!("← {} {}", escape_peer(&call.method), escape_peer(&clip(&Value::Array(call.args.clone()).to_string())));
|
||||
eprintln!("→ {}", escape_peer(&clip(&String::from_utf8_lossy(&wordcraft_zotero::wire::encode_reply(&r)))));
|
||||
}
|
||||
r
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
for a in &host.alerts {
|
||||
eprintln!("Zotero: {a}");
|
||||
eprintln!("Zotero: {}", escape_peer(a));
|
||||
}
|
||||
eprintln!("{} calls, {}", out.calls, if out.completed { "completed" } else { "ended without completing (cancelled?)" });
|
||||
if let Some(o) = arg_value(&rest, "--save") {
|
||||
@@ -687,6 +697,27 @@ fn clip(s: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Text from another program (Zotero, or whatever answered on its port) for one diagnostic
|
||||
/// line: backslashes, control characters (newline, escape, C1…), line separators and bidi
|
||||
/// overrides are shown as escapes (`\\`, `\n`, `\u{1b}`), so it can't end its line, drive the
|
||||
/// terminal or pass for other output.
|
||||
fn escape_peer(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
c if c.is_control() || matches!(c, '\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{2028}'..='\u{202e}' | '\u{2066}'..='\u{2069}') => {
|
||||
out.extend(c.escape_unicode())
|
||||
}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn parity_markdown(p: &Value) -> String {
|
||||
let mut s = String::from(
|
||||
"# WordCraft feature parity\n\nGenerated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry.\n\n",
|
||||
@@ -733,6 +764,15 @@ mod tests {
|
||||
values.iter().map(|value| (*value).into()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peer_text_stays_on_one_visible_line() {
|
||||
let alert = "Done.\nwrote /etc/passwd\r\u{1b}[2J\u{9b}31m\u{202e}txt.exe\\n\tok é";
|
||||
let shown = escape_peer(alert);
|
||||
assert_eq!(shown, "Done.\\nwrote /etc/passwd\\r\\u{1b}[2J\\u{9b}31m\\u{202e}txt.exe\\\\n\\tok é");
|
||||
assert!(!shown.chars().any(|c| c.is_control()));
|
||||
assert_eq!(escape_peer("Citation (Doe, 2020) — “ok”"), "Citation (Doe, 2020) — “ok”");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validators_accept_only_the_documented_options() {
|
||||
assert!(validate_options("info", &args(&["file.docx"]), PASSWORD_OPTIONS).is_ok());
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
//! `wordcraft-cli run` with documents opened along the way: `file.new` and `file.open` open
|
||||
//! another document and make it active, and `--save` saves the active one.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn run(args: &[&str], out: &Path) -> Output {
|
||||
let mut cli = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"));
|
||||
cli.arg("run").args(args).arg("--save").arg(out);
|
||||
cli.output().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_saves_the_document_opened_last() {
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-cli-documents-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (src, out) = (dir.join("opened.txt"), dir.join("out.txt"));
|
||||
std::fs::write(&src, "Opened\n").unwrap();
|
||||
let open = format!(r#"file.open={{"path":{}}}"#, serde_json::json!(src.to_string_lossy()));
|
||||
|
||||
// A script that starts a new document and types into it saves that document.
|
||||
let made = run(&["--template", "sample", "--cmd", "file.new", "--cmd", r#"text.insert={"text":"Fresh"}"#], &out);
|
||||
let fresh = std::fs::read_to_string(&out).unwrap_or_default();
|
||||
// A file opened after typing is what is saved; the typed document stays out of it.
|
||||
let opened = run(&["--template", "blank", "--cmd", r#"text.insert={"text":"Typed"}"#, "--cmd", &open, "--cmd", "caret.docEnd"], &out);
|
||||
let reopened = std::fs::read_to_string(&out).unwrap_or_default();
|
||||
// A run starts with one open document.
|
||||
let mut cli = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"));
|
||||
let listed = cli.args(["run", "--template", "sample", "--print", "--cmd", "view.switchWindows"]).output().unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
assert!(made.status.success(), "stderr: {}", String::from_utf8_lossy(&made.stderr));
|
||||
assert_eq!(fresh.trim(), "Fresh");
|
||||
assert!(opened.status.success(), "stderr: {}", String::from_utf8_lossy(&opened.stderr));
|
||||
assert_eq!(reopened.trim(), "Opened");
|
||||
assert!(listed.status.success(), "stderr: {}", String::from_utf8_lossy(&listed.stderr));
|
||||
let stdout = String::from_utf8_lossy(&listed.stdout);
|
||||
assert_eq!(stdout.matches("\"id\"").count(), 1, "{stdout}");
|
||||
}
|
||||
@@ -146,22 +146,10 @@ fn stored_prefs() -> Option<String> {
|
||||
}
|
||||
|
||||
impl eframe::App for WebShell {
|
||||
/// Runs one browser frame: forwards queued host messages to the status bar, advances the app,
|
||||
/// then writes changed preferences to browser storage immediately, because a reload or closed
|
||||
/// tab never runs the exit path.
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
|
||||
for f in dropped {
|
||||
let inbox = self.inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
|
||||
match f.bytes_async().await {
|
||||
Ok(bytes) => {
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
let msgs = std::mem::take(&mut *self.messages.borrow_mut());
|
||||
for msg in msgs {
|
||||
self.app.status(msg);
|
||||
@@ -180,11 +168,34 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.app.raw_input_hook(raw);
|
||||
/// Forwards this frame's raw browser input to the app before widgets run.
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.app.raw_input_hook(ctx, raw);
|
||||
}
|
||||
|
||||
/// Draws the app. On the first pass of a visible frame it takes the browser's dropped files and
|
||||
/// spawns asynchronous byte reads into the inbox; later passes only draw.
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
// Browser drops are input: take them only in the first real UI pass, never in a hidden
|
||||
// `run_logic` tick that still sees the previous shown frame's RawInput.
|
||||
if ui.ctx().current_pass_index() == 0 {
|
||||
let ctx = ui.ctx().clone();
|
||||
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
|
||||
for f in dropped {
|
||||
let inbox = self.inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
|
||||
match f.bytes_async().await {
|
||||
Ok(bytes) => {
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
self.app.ui(ui);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ wordcraft-fonts = { workspace = true }
|
||||
wordcraft-ui-egui = { workspace = true }
|
||||
# The control key file's location (shared with the MCP bridge).
|
||||
wordcraft-control-key = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "wayland", "x11", "links"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -73,13 +73,16 @@ windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfi
|
||||
clipboard-win = { version = "5.4", features = ["std"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" }
|
||||
rfd = { workspace = true }
|
||||
# Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94);
|
||||
# rfd itself already depends on it.
|
||||
pollster = "0.4"
|
||||
pollster = "1"
|
||||
# Pictures and copied files on the system clipboard (#45); egui's paste only carries text. Already
|
||||
# in the build through egui-winit, with the same features.
|
||||
arboard = { workspace = true, features = ["image-data"] }
|
||||
# The control server's random key.
|
||||
getrandom = "0.3"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
craft-update = { workspace = true }
|
||||
|
||||
@@ -35,14 +35,15 @@ impl AppleEvents {
|
||||
}
|
||||
|
||||
/// Open the documents and act on the quit requests that arrived since the last frame. A document
|
||||
/// opens like File › Open (asking about unsaved changes first); quitting closes the window, which
|
||||
/// asks the same.
|
||||
/// opens like File › Open: beside the open ones, in place of an untouched blank one, or, when it
|
||||
/// is already open, by showing it. Quitting closes the window, which asks about every document
|
||||
/// with unsaved changes.
|
||||
pub fn poll(inbox: &Inbox, app: &mut WordApp, ctx: &egui::Context) {
|
||||
for e in inbox.drain() {
|
||||
match e {
|
||||
Event::Open(paths) => {
|
||||
for p in paths.iter().filter_map(|p| p.to_str()) {
|
||||
match app.run("file.open", serde_json::json!({"path": p})) {
|
||||
match app.open_file(p, None) {
|
||||
// Leave Backstage, as opening from the Open dialog does.
|
||||
Ok(_) => app.ui.backstage = false,
|
||||
Err(e) => {
|
||||
@@ -54,7 +55,12 @@ pub fn poll(inbox: &Inbox, app: &mut WordApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
|
||||
// Quitting asks about every document with unsaved changes, each in its window; the
|
||||
// app closes once that's done (`quit_requested`).
|
||||
Event::Quit => {
|
||||
app.quit();
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,8 @@ const DESKTOP_ID: &str = "ai.storyteller.wordcraft.desktop";
|
||||
|
||||
/// The types the button makes WordCraft the default for on Linux and BSD: Word documents and
|
||||
/// templates (also macro-enabled and legacy), RTF and ODT. The same set the Windows installer
|
||||
/// lists under WordCraft's Capabilities; Markdown and plain text are only offered under Open With.
|
||||
/// lists under WordCraft's Capabilities; Markdown is only offered under Open With, and plain
|
||||
/// text only on Windows and macOS (the `.desktop` file leaves it out, #4137).
|
||||
/// Every one is in the `.desktop` file's `MimeType=` (a test checks).
|
||||
#[cfg(any(all(unix, not(target_os = "macos")), test))]
|
||||
const WORD_MIME_TYPES: &[&str] = &[
|
||||
@@ -164,6 +165,16 @@ mod tests {
|
||||
assert!(entry.lines().any(|l| l == "Exec=wordcraft %F"), "files are passed on the command line");
|
||||
}
|
||||
|
||||
/// Declaring `text/plain` makes WordCraft a handler for every config, script and log on systems with no default set.
|
||||
#[test]
|
||||
fn the_desktop_entry_does_not_declare_plain_text() {
|
||||
let entry = include_str!("../../../packaging/linux/ai.storyteller.wordcraft.desktop");
|
||||
let declared: Vec<&str> =
|
||||
entry.lines().find_map(|l| l.strip_prefix("MimeType=")).map(|v| v.split(';').filter(|s| !s.is_empty()).collect()).unwrap_or_default();
|
||||
assert!(!declared.is_empty(), "MimeType= line not found");
|
||||
assert!(!declared.contains(&"text/plain"), "text/plain must not be declared");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn application_dirs_follow_the_xdg_spec() {
|
||||
|
||||
@@ -208,7 +208,7 @@ fn save_exts(name: &str) -> Vec<String> {
|
||||
let suggested = std::path::Path::new(name)
|
||||
.extension()
|
||||
.and_then(std::ffi::OsStr::to_str)
|
||||
.map(|e| e.to_ascii_lowercase())
|
||||
.map(str::to_ascii_lowercase)
|
||||
.filter(|e| wordcraft_engine::io::SAVE_EXTS.contains(&e.as_str()))
|
||||
.unwrap_or_else(|| "docx".to_string());
|
||||
let mut exts: Vec<String> = Vec::with_capacity(wordcraft_engine::io::SAVE_EXTS.len());
|
||||
|
||||
+48
-11
@@ -27,47 +27,66 @@ mod traffic_lights;
|
||||
use wordcraft_engine::Session;
|
||||
use wordcraft_ui_egui::{
|
||||
Services, UiState, WordApp,
|
||||
window_geometry::{WindowGeometry, take_rescue},
|
||||
window_geometry::{RawWindowState, WindowGeometry, take_rescue},
|
||||
};
|
||||
|
||||
/// The app, the restored window geometry (or, with none, the default: no position) until the
|
||||
/// first frame that knows the monitor has checked it, the control server's key file (removed on
|
||||
/// exit), the file dialogs the app asked for and, on macOS, the documents opened from Finder.
|
||||
/// exit), the file dialogs the app asked for, this frame's raw host window state and, on macOS,
|
||||
/// the documents opened from Finder.
|
||||
struct App(
|
||||
WordApp,
|
||||
Option<WindowGeometry>,
|
||||
Option<control_server::KeyFile>,
|
||||
file_dialogs::Launcher,
|
||||
// The host's answer for this frame. `InputState` still describes the last painted frame on
|
||||
// a hidden logic tick.
|
||||
RawWindowState,
|
||||
#[cfg(target_os = "macos")] fmv_macos_events::Inbox,
|
||||
);
|
||||
|
||||
impl eframe::App for App {
|
||||
/// Advances one desktop frame: polls Finder events on macOS, runs the app and its file dialogs,
|
||||
/// fits or rescues the window and maximizes it after opening, then gates a close or quit request
|
||||
/// on unsaved changes and active printing.
|
||||
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events::poll(&self.4, &mut self.0, ctx);
|
||||
apple_events::poll(&self.5, &mut self.0, ctx);
|
||||
self.0.logic(ctx);
|
||||
self.3.show(frame);
|
||||
let window = self.4;
|
||||
// A window that doesn't fit on its screen (the default size on a laptop) shrinks to fit
|
||||
// there, so all of the scroll bar can be dragged (#1215). Checked on the frame that first
|
||||
// knows the monitor: the off-screen check below takes the pending geometry then.
|
||||
if let Some((pos, size)) = ctx.input(|i| self.1.and_then(|g| g.fit_on_monitor(i.viewport()))) {
|
||||
if let Some((pos, size)) = self.1.and_then(|g| g.fit_on_monitor(&window)) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
|
||||
}
|
||||
// The geometry saved for the next start is the active window's.
|
||||
let prev = self.0.ui.window;
|
||||
self.0.ui.window = ctx.input(|i| WindowGeometry::track(prev, i.viewport(), i.viewport_rect().size()));
|
||||
// Another document window's state is the one it last painted with: the host reports only
|
||||
// the root window's for this frame.
|
||||
let active = self.0.active_viewport();
|
||||
let tracked = if active == egui::ViewportId::ROOT {
|
||||
window
|
||||
} else {
|
||||
ctx.input_for(active, |i| RawWindowState::from_viewport(Some(i.viewport()), Some(i.viewport_rect())))
|
||||
};
|
||||
self.0.ui.window = WindowGeometry::track(prev, &tracked);
|
||||
// The first frame, once the monitor is known (as `take_rescue` waits for): maximize a
|
||||
// window that opened at its normal size so it never flashed on screen half-opened.
|
||||
let maximize = self.1.and_then(|g| g.maximize_after_open()).filter(|_| ctx.input(|i| i.viewport().monitor_size.is_some()));
|
||||
if let Some(pos) = ctx.input(|i| take_rescue(&mut self.1, i.viewport())) {
|
||||
let maximize = self.1.and_then(|g| g.maximize_after_open()).filter(|_| window.monitor_size.is_some());
|
||||
if let Some(pos) = take_rescue(&mut self.1, &window) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
|
||||
}
|
||||
if let Some(command) = maximize {
|
||||
ctx.send_viewport_cmd(command);
|
||||
}
|
||||
// The window's close button: with unsaved changes, ask first (`WordApp::close_requested`).
|
||||
// The window's close button: with other windows open only this window closes (the app
|
||||
// handles that while drawing it); the last one quits, asking first about unsaved changes
|
||||
// (`WordApp::close_requested`).
|
||||
let closing = ctx.input(|i| i.viewport().close_requested());
|
||||
if closing && !self.0.close_requested() {
|
||||
if closing && (self.0.closes_a_window() || !self.0.close_requested()) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
} else if (closing || self.0.quit_requested) && !self.0.printing.busy() {
|
||||
// On macOS, letting eframe drop the window inside `-[NSApplication run]` aborts in
|
||||
@@ -85,8 +104,13 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(raw);
|
||||
/// Records this frame's native window state before forwarding the hook, so logic never reads a
|
||||
/// stale inner rectangle from a hidden tick.
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
// Keep this frame's native window state. `logic` must not read the last painted
|
||||
// `InputState` when a hidden Wayland tick reports no fresh inner rect.
|
||||
self.4 = RawWindowState::from_raw(raw);
|
||||
self.0.raw_input_hook(ctx, raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||
self.0.ui(ui);
|
||||
@@ -320,7 +344,15 @@ fn open_non_unicode(app: &mut WordApp, path: &std::path::Path) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses the command line, installs logging and the panic hook, restores preferences and window
|
||||
/// geometry, opens requested documents or the Start screen, starts the control server when
|
||||
/// asked, and runs the eframe event loop.
|
||||
fn main() -> eframe::Result {
|
||||
// Windows: the update helper is a copy of this program started with `--finish-update`.
|
||||
#[cfg(windows)]
|
||||
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::WORDCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
std::process::exit(i32::from(result.is_err()));
|
||||
}
|
||||
// First, so the panic hook and every start-up warning are recorded (`logging`).
|
||||
let logger = logging::install();
|
||||
logging::install_panic_hook();
|
||||
@@ -397,6 +429,8 @@ fn main() -> eframe::Result {
|
||||
#[cfg(target_os = "macos")]
|
||||
graphics::configure_indirect_validation(&mut options, graphics::skips_indirect_validation(true, cfg!(target_arch = "x86_64")));
|
||||
load_user_fonts();
|
||||
// Windows opened for further documents start like the first one, before its saved place.
|
||||
let window_builder = options.viewport.clone();
|
||||
let restored = saved_window();
|
||||
if let Some(window) = restored {
|
||||
options.viewport = window.apply(options.viewport);
|
||||
@@ -429,6 +463,7 @@ fn main() -> eframe::Result {
|
||||
let mut session = Session::new(doc);
|
||||
session.system_region = region;
|
||||
let mut app = WordApp::new(session, services(file_dialog, &cc.egui_ctx));
|
||||
app.set_startup_update_check(true);
|
||||
load_prefs(&mut app);
|
||||
load_user_dictionary(&mut app);
|
||||
// A region chosen in File › Options replaces the system's for the first blank
|
||||
@@ -447,6 +482,7 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
}
|
||||
app.ui.window = restored;
|
||||
app.set_window_builder(window_builder);
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(bar) = traffic_lights::title_bar() {
|
||||
@@ -480,6 +516,7 @@ fn main() -> eframe::Result {
|
||||
Some(restored.unwrap_or_default()),
|
||||
key_file,
|
||||
dialogs,
|
||||
RawWindowState::default(),
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events.connect(&cc.egui_ctx),
|
||||
)))
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
//! Desktop spooler adapter. Discovery and submission never run on the UI thread.
|
||||
use wordcraft_ui_egui::printing::{Discover, Duplex, Printer, Settings, Submit};
|
||||
|
||||
/// Returns a discovery callback that enumerates spooler printers on a named worker thread and
|
||||
/// sends the list through a channel, keeping spooler calls off the UI thread.
|
||||
pub fn discover() -> Discover {
|
||||
Box::new(|| {
|
||||
let (sender, receiver) = std::sync::mpsc::channel();
|
||||
let _ = std::thread::Builder::new().name("print-discovery".into()).spawn(move || {
|
||||
let printers = craft_print::printers().into_iter().map(|printer| Printer { name: printer.name, default: printer.default }).collect();
|
||||
let printers = craft_print::printers()
|
||||
.into_iter()
|
||||
.map(|printer| Printer { name: printer.name, label: printer.description, default: printer.default })
|
||||
.collect();
|
||||
let _ = sender.send(printers);
|
||||
});
|
||||
receiver
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
04600cbb1508f1b15927ec574a5e32e5f376fd8b
|
||||
9205108cfdced5177706264038848b68b2422d2b
|
||||
|
||||
@@ -8,9 +8,15 @@ description = "WordCraft document model: stories, paragraphs, runs, tables, sect
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
# Test inputs other crates' tests share (`cfb_guard::repeating_difat_sample`); turned on only as a
|
||||
# dev-dependency, so never built into the app.
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
wordcraft-geom = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//! A bytes-only check of an OLE compound file ([MS-CFB]) before the `cfb` crate opens it
|
||||
//! (#3642). Every reader of `.doc`, `.xls` and encrypted Office packages calls [`check`] first.
|
||||
|
||||
/// Check a compound file's FAT layout before `cfb` reads it ([MS-CFB] §2.2 header, §2.5
|
||||
/// DIFAT). `cfb` appends a whole sector of FAT entries for every FAT sector the DIFAT lists and
|
||||
/// only validates the FAT once it is built, so a small header naming one sector over and over
|
||||
/// makes it allocate gigabytes. Here the DIFAT is walked the way `cfb` walks it (header entries,
|
||||
/// then the DIFAT chain, at most one step per sector; trailing zero and free entries trimmed
|
||||
/// the same way) and the file is refused when it lists one FAT sector twice, or more FAT sectors
|
||||
/// than its sectors need (one FAT sector covers `sector_len / 4` of them, plus one spare), so the
|
||||
/// FAT `cfb` builds is about the size the file needs. Anything else that is wrong is left for
|
||||
/// `cfb` to report.
|
||||
pub fn check(bytes: &[u8]) -> Result<(), String> {
|
||||
const MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
|
||||
const FREE: u32 = 0xFFFF_FFFF;
|
||||
const END_OF_CHAIN: u32 = 0xFFFF_FFFE;
|
||||
const HEADER_ENTRIES: usize = 109;
|
||||
let u16_at = |at: usize| bytes.get(at..at.checked_add(2)?).map(|b| u16::from_le_bytes([b[0], b[1]]));
|
||||
let u32_at = |at: usize| bytes.get(at..at.checked_add(4)?).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]));
|
||||
if bytes.len() < 512 || bytes.get(..8) != Some(MAGIC.as_slice()) {
|
||||
return Ok(());
|
||||
}
|
||||
let sector_len: usize = match u16_at(0x1A) {
|
||||
Some(3) => 512,
|
||||
Some(4) => 4096,
|
||||
_ => return Ok(()),
|
||||
};
|
||||
if bytes.len() < sector_len {
|
||||
return Ok(());
|
||||
}
|
||||
// `cfb` counts the sectors after the header sector, the last one possibly partial.
|
||||
let num_sectors = bytes.len().div_ceil(sector_len) - 1;
|
||||
let num_fat_sectors = u32_at(0x2C).unwrap_or(0) as usize;
|
||||
let mut difat: Vec<u32> = (0..HEADER_ENTRIES).filter_map(|i| u32_at(0x4C + i * 4)).collect();
|
||||
let mut next = u32_at(0x44).unwrap_or(END_OF_CHAIN);
|
||||
let mut steps = 0usize;
|
||||
while next != END_OF_CHAIN && next != FREE {
|
||||
steps += 1;
|
||||
// `cfb` refuses a chain sector past the file or seen before; a chain longer than the
|
||||
// file has sectors must have done one of the two.
|
||||
if steps > num_sectors || next as usize >= num_sectors {
|
||||
return Err(format!("the DIFAT chain is longer than the file or leaves it (sector {next})"));
|
||||
}
|
||||
let at = (next as usize + 1) * sector_len;
|
||||
let entries = sector_len / 4 - 1;
|
||||
difat.extend((0..entries).map(|i| u32_at(at + i * 4).unwrap_or(FREE)));
|
||||
next = u32_at(at + entries * 4).unwrap_or(END_OF_CHAIN);
|
||||
}
|
||||
while difat.len() > HEADER_ENTRIES && difat.len() > num_fat_sectors && difat.last() == Some(&0) {
|
||||
difat.pop();
|
||||
}
|
||||
while difat.last() == Some(&FREE) {
|
||||
difat.pop();
|
||||
}
|
||||
// Each listed FAT sector adds a whole sector of entries. A FAT sector holds the entries of
|
||||
// `sector_len / 4` sectors, so a file needs no more FAT sectors than that covers (one spare),
|
||||
// and none of them twice. A free entry in the middle is left for `cfb` to refuse.
|
||||
let listed: Vec<u32> = difat.into_iter().filter(|&s| s != FREE).collect();
|
||||
let needed = num_sectors.div_ceil(sector_len / 4).saturating_add(1);
|
||||
if listed.len() > needed {
|
||||
return Err(format!("the DIFAT lists {} FAT sectors in a file of {num_sectors} sectors", listed.len()));
|
||||
}
|
||||
let mut seen = std::collections::HashSet::with_capacity(listed.len());
|
||||
if let Some(dup) = listed.iter().find(|&&s| !seen.insert(s)) {
|
||||
return Err(format!("the DIFAT lists FAT sector {dup} twice"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A compound file of about 1 MB whose DIFAT chain lists one FAT sector 2048 × 127 times:
|
||||
/// without [`check`], `cfb` builds a FAT of about 130 MB for it. Regression input for the
|
||||
/// readers' tests: built only for tests, this crate's and (through the `test-support` feature,
|
||||
/// which they turn on as a dev-dependency) the readers', never into the app.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn repeating_difat_sample() -> Vec<u8> {
|
||||
const S: usize = 512;
|
||||
const CHAIN: u32 = 2048; // DIFAT sectors 1..=CHAIN, sector 0 is the "FAT"
|
||||
let mut f = vec![0u8; S * (CHAIN as usize + 2)];
|
||||
let mut put = |at: usize, v: &[u8]| {
|
||||
if let Some(dst) = f.get_mut(at..at + v.len()) {
|
||||
dst.copy_from_slice(v);
|
||||
}
|
||||
};
|
||||
put(0, &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]);
|
||||
put(0x18, &0x3Eu16.to_le_bytes());
|
||||
put(0x1A, &3u16.to_le_bytes());
|
||||
put(0x1C, &0xFFFEu16.to_le_bytes());
|
||||
put(0x1E, &9u16.to_le_bytes());
|
||||
put(0x20, &6u16.to_le_bytes());
|
||||
put(0x2C, &u32::MAX.to_le_bytes()); // no zero trimming
|
||||
put(0x38, &4096u32.to_le_bytes());
|
||||
put(0x44, &1u32.to_le_bytes());
|
||||
put(0x48, &CHAIN.to_le_bytes());
|
||||
// The 109 header entries are all 0 already; each chain sector's entries too.
|
||||
for d in 1..=CHAIN {
|
||||
let next = if d == CHAIN { 0xFFFF_FFFE } else { d + 1 };
|
||||
put((d as usize + 2) * S - 4, &next.to_le_bytes());
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn a_repeating_difat_is_refused_and_a_small_file_passes() {
|
||||
assert!(super::check(&super::repeating_difat_sample()).is_err_and(|e| e.contains("FAT sectors in a file of")));
|
||||
assert!(super::check(b"not a compound file").is_ok());
|
||||
}
|
||||
|
||||
/// A 512-byte-sector file of `sectors` sectors after the header, whose header DIFAT lists
|
||||
/// `fat` (the rest free).
|
||||
fn header_difat(sectors: usize, fat: &[u32]) -> Vec<u8> {
|
||||
let mut f = vec![0u8; 512 * (sectors + 1)];
|
||||
f[..8].copy_from_slice(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]);
|
||||
f[0x1A..0x1C].copy_from_slice(&3u16.to_le_bytes());
|
||||
f[0x2C..0x30].copy_from_slice(&(fat.len() as u32).to_le_bytes());
|
||||
f[0x44..0x48].copy_from_slice(&0xFFFF_FFFEu32.to_le_bytes());
|
||||
for i in 0..109 {
|
||||
let v = fat.get(i).copied().unwrap_or(u32::MAX);
|
||||
f[0x4C + i * 4..0x50 + i * 4].copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fat_sector_listed_twice_or_more_than_the_file_needs_is_refused() {
|
||||
// Each list is shorter than the file has sectors, which the guard used to accept.
|
||||
assert!(super::check(&header_difat(10, &[0, 0])).is_err_and(|e| e.contains("FAT sector 0 twice")));
|
||||
assert!(super::check(&header_difat(10, &[0, 1, 2])).is_err_and(|e| e.contains("3 FAT sectors")));
|
||||
assert!(super::check(&header_difat(10, &[0])).is_ok());
|
||||
assert!(super::check(&header_difat(200, &[0, 1])).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -238,7 +238,7 @@ pub(crate) fn ideograph_zodiac_traditional(n: u32) -> String {
|
||||
|
||||
/// The `n`th (1-based) item of a sequence, or decimal past its end.
|
||||
fn nth(set: &[char], n: u32) -> String {
|
||||
n.checked_sub(1).and_then(|i| set.get(i as usize)).map(|c| c.to_string()).unwrap_or_else(|| n.to_string())
|
||||
n.checked_sub(1).and_then(|i| set.get(i as usize)).map(ToString::to_string).unwrap_or_else(|| n.to_string())
|
||||
}
|
||||
|
||||
/// Code points `first..` enclosing 1 to `count`, decimal beyond.
|
||||
|
||||
+45
-21
@@ -14,6 +14,8 @@
|
||||
//! outside what's deleted (so deleting across a control's edge empties it rather than breaking
|
||||
//! it); writers call [`Document::balance_controls`] so a saved file always has whole controls.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::para::OBJ;
|
||||
@@ -312,10 +314,10 @@ pub fn control_events(bl: &Blocks) -> Vec<ControlEvent<'_>> {
|
||||
if !p.objects.iter().any(|o| matches!(o, InlineObject::ControlStart { .. } | InlineObject::ControlEnd)) {
|
||||
continue;
|
||||
}
|
||||
for off in p.object_offsets() {
|
||||
match p.object_at(off) {
|
||||
Some(InlineObject::ControlStart { control }) => out.push(ControlEvent::Open { block: i, off: Some(off), control }),
|
||||
Some(InlineObject::ControlEnd) => out.push(ControlEvent::Close { block: i, off: Some(off) }),
|
||||
for (off, o) in p.objects_with_offsets() {
|
||||
match o {
|
||||
InlineObject::ControlStart { control } => out.push(ControlEvent::Open { block: i, off: Some(off), control }),
|
||||
InlineObject::ControlEnd => out.push(ControlEvent::Close { block: i, off: Some(off) }),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -639,34 +641,52 @@ impl Document {
|
||||
out.push((r.end.path.clone(), r.end.off));
|
||||
}
|
||||
}
|
||||
// Markers paired with a table wrap: their partner is never deleted with them.
|
||||
// Markers paired with a table wrap: their partner is never deleted with them. Each lookup
|
||||
// is a set probe, and each container is paired once, not once per marker (#3614).
|
||||
let in_ranges: BTreeSet<(&Path, usize)> = ranges.iter().flat_map(|r| [(&r.start.path, r.start.off), (&r.end.path, r.end.off)]).collect();
|
||||
let mut table_paired: BTreeMap<&[u32], BTreeSet<(usize, usize)>> = BTreeMap::new();
|
||||
for path in &paths {
|
||||
let Some(p) = self.para(a.story, path) else { continue };
|
||||
for off in p.object_offsets() {
|
||||
if !inside(path, off) || !matches!(p.object_at(off), Some(InlineObject::ControlStart { .. } | InlineObject::ControlEnd)) {
|
||||
for (off, o) in p.objects_with_offsets() {
|
||||
if !inside(path, off)
|
||||
|| !matches!(o, InlineObject::ControlStart { .. } | InlineObject::ControlEnd)
|
||||
|| in_ranges.contains(&(path, off))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let paired = ranges.iter().any(|r| (r.start.path == *path && r.start.off == off) || (r.end.path == *path && r.end.off == off));
|
||||
if !paired && self.paired_with_table(a.story, path, off) {
|
||||
let paired = table_paired.entry(path.parent()).or_insert_with(|| self.table_paired_markers(a.story, path));
|
||||
if paired.contains(&(path.last(), off)) {
|
||||
out.push((path.clone(), off));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sorted and without repeats: `delete_range` groups this by paragraph and binary-searches
|
||||
// each group (`control_markers_kept_are_sorted` pins it).
|
||||
out.sort();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether the marker at `path`/`off` is paired with a table wrap's open or close.
|
||||
fn paired_with_table(&self, s: StoryRef, path: &Path, off: usize) -> bool {
|
||||
let Some(bl) = self.container(s, path) else { return false };
|
||||
/// (block, offset) of the paragraph markers in `path`'s container that are paired with a
|
||||
/// table wrap's open or close.
|
||||
fn table_paired_markers(&self, s: StoryRef, path: &Path) -> BTreeSet<(usize, usize)> {
|
||||
let Some(bl) = self.container(s, path) else { return BTreeSet::new() };
|
||||
let events = control_events(bl);
|
||||
let (pairs, _) = pair_events(&events);
|
||||
let me = events.iter().position(|e| e.block() == path.last() && e.off() == Some(off));
|
||||
let Some(me) = me else { return false };
|
||||
pairs.iter().any(|(o, c, _)| {
|
||||
(*o == me && events.get(*c).is_some_and(|e| e.off().is_none())) || (*c == me && events.get(*o).is_some_and(|e| e.off().is_none()))
|
||||
})
|
||||
let mut out = BTreeSet::new();
|
||||
for (o, c, _) in pairs {
|
||||
let (Some(open), Some(close)) = (events.get(o), events.get(c)) else { continue };
|
||||
match (open.off(), close.off()) {
|
||||
(Some(off), None) => {
|
||||
out.insert((open.block(), off));
|
||||
}
|
||||
(None, Some(off)) => {
|
||||
out.insert((close.block(), off));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Where typing at `p` should go: a caret at the start of a paragraph, before the start of
|
||||
@@ -674,8 +694,11 @@ impl Document {
|
||||
/// end, goes inside it too (the control is around the whole paragraph).
|
||||
pub fn typing_pos(&self, p: &Pos) -> Pos {
|
||||
let Some(para) = self.para_at(p) else { return p.clone() };
|
||||
let is_start = |off: usize| matches!(para.object_at(off), Some(InlineObject::ControlStart { control }) if control.block);
|
||||
let is_end = |off: usize| matches!(para.object_at(off), Some(InlineObject::ControlEnd));
|
||||
// The objects once, looked up by offset (object_at per step rescans the text).
|
||||
let objs: Vec<(usize, &InlineObject)> = para.objects_with_offsets().collect();
|
||||
let obj = |off: usize| objs.binary_search_by_key(&off, |(o, _)| *o).ok().and_then(|k| objs.get(k)).map(|(_, o)| *o);
|
||||
let is_start = |off: usize| matches!(obj(off), Some(InlineObject::ControlStart { control }) if control.block);
|
||||
let is_end = |off: usize| matches!(obj(off), Some(InlineObject::ControlEnd));
|
||||
let w = OBJ.len_utf8();
|
||||
let mut off = p.off;
|
||||
// Only block starts before the caret (or none) and block starts right after it.
|
||||
@@ -688,8 +711,9 @@ impl Document {
|
||||
}
|
||||
if p.off == para.len() && off >= w && is_end(off - w) {
|
||||
// Step back over the trailing ends that close block-level controls.
|
||||
let ranges = self.control_ranges(p.story);
|
||||
while off >= w && is_end(off - w) && ranges.iter().any(|r| r.end.path == p.path && r.end.off == off - w && r.control.block) {
|
||||
let block_ends: BTreeSet<usize> =
|
||||
self.control_ranges(p.story).into_iter().filter(|r| r.end.path == p.path && r.control.block).map(|r| r.end.off).collect();
|
||||
while off >= w && is_end(off - w) && block_ends.contains(&(off - w)) {
|
||||
off -= w;
|
||||
}
|
||||
return Pos { off, ..p.clone() };
|
||||
|
||||
+312
-19
@@ -7,7 +7,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::para::InlineObject;
|
||||
use crate::para::{InlineObject, Run};
|
||||
use crate::props::{CharProps, ParaProps};
|
||||
use crate::{Block, Blocks, DocError, Document, Paragraph, Part, PartKind, Path, Pos, Result, para_block};
|
||||
use crate::{Table, table::MAX_TABLE_DEPTH};
|
||||
@@ -188,6 +188,111 @@ fn fit_tables(b: Block, room: usize, out: &mut Vec<Block>) {
|
||||
const MAX_BOX_DEPTH: usize = 8;
|
||||
/// Most text box stories one paste may create (hostile documents can fan out).
|
||||
const MAX_PASTED_BOXES: usize = 4096;
|
||||
/// Most text box content (estimated bytes, see [`block_cost`]) one paste may copy: thousands of
|
||||
/// boxes can show the same story, and each needs a story of its own. Blocks without text boxes
|
||||
/// are shared between those stories, not copied, so they are not charged per box.
|
||||
const MAX_PASTED_BOX_BYTES: usize = 32 << 20;
|
||||
|
||||
/// What a paste may still spend on text box stories.
|
||||
struct BoxBudget {
|
||||
stories: usize,
|
||||
bytes: usize,
|
||||
/// The source stories being copied, innermost last: a box inside one of them showing it
|
||||
/// again is a cycle.
|
||||
active: Vec<u32>,
|
||||
/// Each source story's cost ([`block_cost`]), worked out once per paste: a story too big for
|
||||
/// what is left is not walked again for every box that shows it.
|
||||
costs: BTreeMap<u32, usize>,
|
||||
/// Boxes left without a story because the paste's budget couldn't hold theirs.
|
||||
skipped: usize,
|
||||
}
|
||||
|
||||
impl BoxBudget {
|
||||
fn new() -> BoxBudget {
|
||||
BoxBudget { stories: MAX_PASTED_BOXES, bytes: MAX_PASTED_BOX_BYTES, active: Vec::new(), costs: BTreeMap::new(), skipped: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Rough size of a block's content, and whether it holds text boxes (and so must be copied).
|
||||
fn block_cost(b: &Block) -> (usize, bool) {
|
||||
let (mut cost, mut boxes) = (std::mem::size_of::<Block>(), false);
|
||||
each_para(b, 0, &mut |p| {
|
||||
let items = p
|
||||
.runs
|
||||
.len()
|
||||
.saturating_mul(std::mem::size_of::<Run>())
|
||||
.saturating_add(p.objects.len().saturating_mul(std::mem::size_of::<InlineObject>()));
|
||||
let objects = p.objects.iter().fold(0usize, |n, o| n.saturating_add(object_cost(o)));
|
||||
cost = cost.saturating_add(p.text.len()).saturating_add(items).saturating_add(objects);
|
||||
boxes |= box_ids(p).next().is_some();
|
||||
});
|
||||
(cost, boxes)
|
||||
}
|
||||
|
||||
/// Rough heap bytes a clone of `o` copies beyond its own storage: its strings and lists, and its
|
||||
/// group members however deep. What it shares through `Arc` (pictures' embedded objects, charts,
|
||||
/// freeforms, wrap polygons, gradients) is not copied, so not counted.
|
||||
fn object_cost(o: &InlineObject) -> usize {
|
||||
let float = |f: &crate::para::Float| f.alt.text.len().saturating_add(f.alt.title.len());
|
||||
let mut cost = 0usize;
|
||||
let mut todo = vec![o];
|
||||
while let Some(o) = todo.pop() {
|
||||
let add = match o {
|
||||
InlineObject::Image { media, alt, float: f, .. } => media.len().saturating_add(alt.len()).saturating_add(float(f)),
|
||||
InlineObject::Graphic { alt, float: f, .. } => alt.len().saturating_add(float(f)),
|
||||
InlineObject::Shape { float: f, extra, .. } => {
|
||||
let warp = extra.warp.as_ref().map_or(0, |w| {
|
||||
let adj = w.adj.iter().map(|(name, _)| std::mem::size_of::<(String, i64)>().saturating_add(name.len()));
|
||||
adj.fold(w.preset.len(), usize::saturating_add)
|
||||
});
|
||||
float(f).saturating_add(warp)
|
||||
}
|
||||
InlineObject::Group { float: f, children, .. } => {
|
||||
todo.extend(children.iter().map(|c| &c.obj));
|
||||
children.len().saturating_mul(std::mem::size_of::<crate::para::GroupChild>()).saturating_add(float(f))
|
||||
}
|
||||
InlineObject::Field { instr, result, form, .. } => instr.len().saturating_add(result.len()).saturating_add(form.len()),
|
||||
InlineObject::FieldStart { instr, .. } => instr.len(),
|
||||
InlineObject::ControlStart { control } => std::mem::size_of::<crate::control::ContentControl>().saturating_add(serialized_len(control)),
|
||||
InlineObject::NoteRef { custom, .. } => custom.len(),
|
||||
InlineObject::BookmarkStart { name } | InlineObject::BookmarkEnd { name } => name.len(),
|
||||
InlineObject::PermStart { group, editor, .. } => group.len().saturating_add(editor.len()),
|
||||
InlineObject::Equation { linear, math, .. } => linear.len().saturating_add(math.heap_bytes()),
|
||||
InlineObject::Ruby { base, ruby, lang, props, .. } => {
|
||||
[base.len(), ruby.len(), lang.len(), std::mem::size_of::<CharProps>(), serialized_len(props)]
|
||||
.into_iter()
|
||||
.fold(0, usize::saturating_add)
|
||||
}
|
||||
InlineObject::Opaque { format, xml, text } => format.len().saturating_add(xml.len()).saturating_add(text.len()),
|
||||
InlineObject::FieldEnd
|
||||
| InlineObject::ControlEnd
|
||||
| InlineObject::CommentStart { .. }
|
||||
| InlineObject::CommentEnd { .. }
|
||||
| InlineObject::PermEnd { .. }
|
||||
| InlineObject::PositionalTab { .. } => 0,
|
||||
};
|
||||
cost = cost.saturating_add(add);
|
||||
}
|
||||
cost
|
||||
}
|
||||
|
||||
/// Length of `v` written as JSON: at least the bytes of every string and list it owns. For the
|
||||
/// nested formatting and control types, which share nothing through `Arc`.
|
||||
fn serialized_len<T: Serialize>(v: &T) -> usize {
|
||||
struct Count(usize);
|
||||
impl std::io::Write for Count {
|
||||
fn write(&mut self, b: &[u8]) -> std::io::Result<usize> {
|
||||
self.0 = self.0.saturating_add(b.len());
|
||||
Ok(b.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let mut n = Count(0);
|
||||
let _ = serde_json::to_writer(&mut n, v);
|
||||
n.0
|
||||
}
|
||||
|
||||
/// Call `f` on every paragraph of a block, in document order, however deep its tables nest. The
|
||||
/// walk keeps its own stack instead of recursing, so it is complete: a text box it doesn't reach
|
||||
@@ -474,7 +579,13 @@ impl Document {
|
||||
}
|
||||
// Content control markers whose partner is outside the range stay (the control is
|
||||
// emptied, not broken).
|
||||
let kept = self.kept_control_markers(&a, &b);
|
||||
let mut kept = self.kept_control_markers(&a, &b);
|
||||
// The walk below groups `kept` by paragraph (`chunk_by`) and looks offsets up
|
||||
// (`binary_search`), so it must be sorted; `kept_control_markers` returns it so, and this
|
||||
// keeps a change there from quietly losing markers.
|
||||
if !kept.is_sorted() {
|
||||
kept.sort();
|
||||
}
|
||||
let kept_in = |path: &Path| -> Vec<usize> { kept.iter().filter(|(p, _)| p == path).map(|(_, o)| *o).collect() };
|
||||
if a.path == b.path {
|
||||
self.para_mut(a.story, &a.path)?.delete_except(a.off, b.off, &kept_in(&a.path))?;
|
||||
@@ -485,13 +596,21 @@ impl Document {
|
||||
let (ia, ib) = (a.path.last(), b.path.last());
|
||||
// Kept markers of the paragraphs in between move to the join.
|
||||
let mut moved: Vec<(InlineObject, CharProps)> = Vec::new();
|
||||
for (path, off) in &kept {
|
||||
if *path != a.path
|
||||
&& *path != b.path
|
||||
&& let Some(p) = self.para(a.story, path)
|
||||
&& let Some(o) = p.object_at(*off)
|
||||
{
|
||||
moved.push((o.clone(), p.props_of_char(*off).clone()));
|
||||
// `kept` is sorted: one walk of each paragraph's objects and runs (#3614).
|
||||
for group in kept.chunk_by(|x, y| x.0 == y.0) {
|
||||
let Some((path, _)) = group.first() else { continue };
|
||||
if *path == a.path || *path == b.path {
|
||||
continue;
|
||||
}
|
||||
let Some(p) = self.para(a.story, path) else { continue };
|
||||
let mut runs = p.run_ranges().peekable();
|
||||
for (off, o) in p.objects_with_offsets() {
|
||||
if group.binary_search_by_key(&off, |(_, k)| *k).is_err() {
|
||||
continue;
|
||||
}
|
||||
while runs.next_if(|(r, _)| r.end <= off).is_some() {}
|
||||
let props = runs.peek().filter(|(r, _)| r.contains(&off)).map_or(&p.mark, |(_, c)| *c);
|
||||
moved.push((o.clone(), props.clone()));
|
||||
}
|
||||
}
|
||||
{
|
||||
@@ -744,7 +863,7 @@ impl Document {
|
||||
/// Give every text box in `blocks` a new story of its own, copied from `parts` (or, for a
|
||||
/// fragment without them, from this document's text box of that id). A box whose story can't
|
||||
/// be found keeps no story rather than showing an unrelated one.
|
||||
fn adopt_text_boxes(&mut self, blocks: &mut [Block], parts: &BTreeMap<u32, Part>, depth: usize, budget: &mut usize) {
|
||||
fn adopt_text_boxes(&mut self, blocks: &mut [Block], parts: &BTreeMap<u32, Part>, depth: usize, budget: &mut BoxBudget) {
|
||||
let mut any = false;
|
||||
for b in blocks.iter() {
|
||||
each_para(b, 0, &mut |p| any |= box_ids(p).next().is_some());
|
||||
@@ -770,18 +889,53 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// A new text box story copied from `old`; its own boxes get new stories too.
|
||||
fn adopt_text_box(&mut self, old: u32, parts: &BTreeMap<u32, Part>, depth: usize, budget: &mut usize) -> Option<u32> {
|
||||
if depth >= MAX_BOX_DEPTH || *budget == 0 {
|
||||
/// A new text box story copied from `old`; its own boxes get new stories too. Blocks without
|
||||
/// text boxes are shared with the source (copy-on-write), the others copied. A box showing a
|
||||
/// story it is inside, or whose story the paste's budget can't hold, gets none (CWE-400);
|
||||
/// only that box: a later box with a smaller story is still copied while the budget lasts.
|
||||
fn adopt_text_box(&mut self, old: u32, parts: &BTreeMap<u32, Part>, depth: usize, budget: &mut BoxBudget) -> Option<u32> {
|
||||
if depth >= MAX_BOX_DEPTH || budget.active.contains(&old) {
|
||||
return None;
|
||||
}
|
||||
if budget.stories == 0 {
|
||||
budget.skipped = budget.skipped.saturating_add(1);
|
||||
return None;
|
||||
}
|
||||
let src = parts.get(&old).or_else(|| self.parts.get(&old).filter(|p| p.kind == PartKind::TextBox))?;
|
||||
let mut blocks: Vec<Block> = src.blocks.iter().map(|b| (**b).clone()).collect();
|
||||
// Charge what the copy really allocates before making any of it: the block slots, and the
|
||||
// blocks holding text boxes (deep-copied below). The rest are shared `Arc`s, however many
|
||||
// boxes show them, so they cost nothing more. Worked out once per paste.
|
||||
let cost = *budget.costs.entry(old).or_insert_with(|| {
|
||||
let slots = src.blocks.len().saturating_mul(std::mem::size_of::<Arc<Block>>());
|
||||
src.blocks.iter().fold(slots, |a, b| match block_cost(b) {
|
||||
(c, true) => a.saturating_add(c),
|
||||
(_, false) => a,
|
||||
})
|
||||
});
|
||||
let Some(left) = budget.bytes.checked_sub(cost) else {
|
||||
budget.skipped = budget.skipped.saturating_add(1);
|
||||
return None;
|
||||
};
|
||||
budget.bytes = left;
|
||||
budget.stories -= 1;
|
||||
let src_blocks = src.blocks.clone();
|
||||
// The copy keeps its text direction and alignment, not its link (a copy stands alone).
|
||||
let body = crate::TextBody { next: None, ..src.body };
|
||||
*budget -= 1;
|
||||
self.adopt_text_boxes(&mut blocks, parts, depth + 1, budget);
|
||||
let id = self.try_add_part(PartKind::TextBox, blocks.into_iter().map(Arc::new).collect()).ok()?;
|
||||
budget.active.push(old);
|
||||
let mut blocks: Blocks = Vec::with_capacity(src_blocks.len());
|
||||
for b in src_blocks {
|
||||
let mut boxes = false;
|
||||
each_para(&b, 0, &mut |p| boxes |= box_ids(p).next().is_some());
|
||||
if boxes {
|
||||
let mut copy = (*b).clone();
|
||||
self.adopt_text_boxes(std::slice::from_mut(&mut copy), parts, depth + 1, budget);
|
||||
blocks.push(Arc::new(copy));
|
||||
} else {
|
||||
blocks.push(b);
|
||||
}
|
||||
}
|
||||
budget.active.pop();
|
||||
let id = self.try_add_part(PartKind::TextBox, blocks).ok()?;
|
||||
if let Some(p) = self.parts.get_mut(&id) {
|
||||
p.body = body;
|
||||
}
|
||||
@@ -803,8 +957,15 @@ impl Document {
|
||||
return Err(DocError::BadPath(pos.path.to_string()));
|
||||
}
|
||||
// Pasted text boxes get stories of their own (never shared with the copy's source).
|
||||
let mut budget = MAX_PASTED_BOXES;
|
||||
let mut budget = BoxBudget::new();
|
||||
self.adopt_text_boxes(&mut blocks, &frag.parts, 0, &mut budget);
|
||||
if budget.skipped > 0 {
|
||||
// Once per paste, not once per box (review of #3874).
|
||||
log::warn!(
|
||||
"paste: {} text boxes are left empty; their stories are past what one paste copies ({MAX_PASTED_BOXES} stories, {MAX_PASTED_BOX_BYTES} bytes)",
|
||||
budget.skipped
|
||||
);
|
||||
}
|
||||
// Single paragraph: inline insert keeping runs.
|
||||
if blocks.len() == 1
|
||||
&& let Some(Block::Para(src)) = blocks.first()
|
||||
@@ -1196,7 +1357,139 @@ mod tests {
|
||||
let before = d.parts.len();
|
||||
d.insert_fragment(&Pos::body(0, 0), &f).unwrap();
|
||||
let added = d.parts.len() - before;
|
||||
assert!((1..=MAX_BOX_DEPTH).contains(&added), "{added}");
|
||||
// The copy's inner box would show the story being copied: a cycle, so it gets none.
|
||||
assert_eq!(added, 1);
|
||||
}
|
||||
|
||||
/// A fragment whose body is one paragraph of `refs` text boxes all showing story 1.
|
||||
fn fan_out(story: Vec<Arc<Block>>, refs: usize) -> Fragment {
|
||||
let mut p = Paragraph::new();
|
||||
for _ in 0..refs {
|
||||
p.insert_object(0, box_shape(Some(1)), &CharProps::default()).unwrap();
|
||||
}
|
||||
let mut parts = BTreeMap::new();
|
||||
parts.insert(1, crate::Part { kind: PartKind::TextBox, blocks: story, body: Default::default() });
|
||||
Fragment { blocks: vec![Block::Para(p)], parts }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_boxes_share_the_blocks_they_need_not_copy() {
|
||||
let big = para_block(Paragraph::with_text(&"x".repeat(100_000), CharProps::default()));
|
||||
let f = fan_out(vec![big.clone()], 3);
|
||||
let mut d = doc(&["x"]);
|
||||
d.insert_fragment(&Pos::body(0, 1), &f).unwrap();
|
||||
let got = boxes_in(&d, &Path::top(0));
|
||||
assert_eq!(got.len(), 3);
|
||||
let ids: Vec<u32> = got.into_iter().flatten().collect();
|
||||
assert_eq!(ids.len(), 3, "each box has a story");
|
||||
assert!(ids.windows(2).all(|w| w[0] != w[1]), "stories of their own: {ids:?}");
|
||||
for id in ids {
|
||||
let b = d.parts[&id].blocks.first().unwrap();
|
||||
assert!(Arc::ptr_eq(b, &big), "no text box inside: shared, not copied");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_box_over_the_budget_leaves_later_smaller_boxes_their_stories() {
|
||||
// Review of #3874: once one story didn't fit, every later box in the paste was left
|
||||
// empty, however small its story. Only the box that doesn't fit is.
|
||||
// (Story 1 holds a text box, so each box showing it needs a real copy.)
|
||||
let mut bp = Paragraph::with_text(&"x".repeat(100_000), CharProps::default());
|
||||
bp.insert_object(0, box_shape(Some(999)), &CharProps::default()).unwrap();
|
||||
let big = para_block(bp);
|
||||
let small = para_block(Paragraph::with_text("small", CharProps::default()));
|
||||
let mut p = Paragraph::new();
|
||||
for story in [2, 1, 2, 1] {
|
||||
p.insert_object(0, box_shape(Some(story)), &CharProps::default()).unwrap();
|
||||
}
|
||||
let mut parts = BTreeMap::new();
|
||||
parts.insert(1, crate::Part { kind: PartKind::TextBox, blocks: vec![big], body: Default::default() });
|
||||
parts.insert(2, crate::Part { kind: PartKind::TextBox, blocks: vec![small], body: Default::default() });
|
||||
let mut blocks = vec![Block::Para(p)];
|
||||
let mut d = doc(&["x"]);
|
||||
let mut budget = BoxBudget { bytes: 50_000, ..BoxBudget::new() };
|
||||
d.adopt_text_boxes(&mut blocks, &parts, 0, &mut budget);
|
||||
let Some(Block::Para(p)) = blocks.first() else { panic!() };
|
||||
let got: Vec<Option<u32>> = p.objects.iter().map(|o| if let InlineObject::Shape { story, .. } = o { *story } else { None }).collect();
|
||||
assert_eq!(got.iter().map(Option::is_some).collect::<Vec<_>>(), [false, true, false, true], "{got:?}");
|
||||
assert_eq!(budget.skipped, 2);
|
||||
assert_eq!(budget.costs.len(), 2, "each story costed once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_boxes_sharing_one_story_paste_with_their_text() {
|
||||
// Review of #3874: a document converted from PDF has thousands of boxes showing story
|
||||
// data they share. 64 boxes x a 1 MiB block is 64 MiB counted per box (over the 32 MiB
|
||||
// cap) but the block is shared, not copied, so every box keeps its text.
|
||||
let big = para_block(Paragraph::with_text(&"x".repeat(1 << 20), CharProps::default()));
|
||||
let f = fan_out(vec![big.clone()], 64);
|
||||
let mut d = doc(&["x"]);
|
||||
let (_, warned) = crate::test_log::warnings(|| d.insert_fragment(&Pos::body(0, 1), &f).unwrap());
|
||||
assert!(!warned.iter().any(|w| w.contains("text boxes are left empty")), "{warned:?}");
|
||||
let ids: Vec<u32> = boxes_in(&d, &Path::top(0)).into_iter().flatten().collect();
|
||||
assert_eq!(ids.len(), 64, "every box keeps a story");
|
||||
for id in ids {
|
||||
let b = d.parts[&id].blocks.first().unwrap();
|
||||
assert!(Arc::ptr_eq(b, &big), "shared, so not charged per box");
|
||||
assert_eq!(d.parts[&id].blocks.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_box_copies_are_capped_in_bytes() {
|
||||
// Story 1 is a 1 MiB paragraph that holds a text box itself, so each box showing it needs
|
||||
// its own copy; 64 of them used to copy 64 MiB.
|
||||
let mut p = Paragraph::with_text(&"x".repeat(1 << 20), CharProps::default());
|
||||
p.insert_object(0, box_shape(Some(999)), &CharProps::default()).unwrap();
|
||||
let f = fan_out(vec![para_block(p)], 64);
|
||||
let mut d = doc(&["x"]);
|
||||
let before = d.parts.len();
|
||||
let (_, warned) = crate::test_log::warnings(|| d.insert_fragment(&Pos::body(0, 1), &f).unwrap());
|
||||
let added = d.parts.len() - before;
|
||||
// The boxes left empty are said to be, once for the paste (review of #3874).
|
||||
let empty: Vec<_> = warned.iter().filter(|w| w.contains("text boxes are left empty")).collect();
|
||||
assert_eq!(empty.len(), 1, "{warned:?}");
|
||||
assert!(empty[0].starts_with(&format!("paste: {} text boxes", 64 - added)), "{empty:?}");
|
||||
let copied: usize = d.parts.values().flat_map(|p| p.blocks.iter()).map(|b| if let Block::Para(p) = &**b { p.text.len() } else { 0 }).sum();
|
||||
assert!((1..64).contains(&added), "{added}");
|
||||
assert!(copied <= MAX_PASTED_BOX_BYTES, "{copied}");
|
||||
// The boxes past the cap keep no story rather than a shared one.
|
||||
let got = boxes_in(&d, &Path::top(0));
|
||||
assert_eq!(got.iter().flatten().count(), added);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_box_copies_count_the_objects_they_copy() {
|
||||
// #3614 review: a story holding a text box and a 1 MiB kept-for-round-trip object costs
|
||||
// the object's XML too, not only its slot: 64 copies used to copy 64 MiB of it.
|
||||
let mut p = Paragraph::with_text("x", CharProps::default());
|
||||
p.insert_object(0, box_shape(Some(999)), &CharProps::default()).unwrap();
|
||||
let opaque = InlineObject::Opaque { format: "docx".into(), xml: "x".repeat(1 << 20), text: String::new() };
|
||||
p.insert_object(0, opaque, &CharProps::default()).unwrap();
|
||||
let f = fan_out(vec![para_block(p)], 64);
|
||||
let mut d = doc(&["x"]);
|
||||
let before = d.parts.len();
|
||||
d.insert_fragment(&Pos::body(0, 1), &f).unwrap();
|
||||
let added = d.parts.len() - before;
|
||||
let xml = |o: &InlineObject| if let InlineObject::Opaque { xml, .. } = o { xml.len() } else { 0 };
|
||||
let copied: usize =
|
||||
d.parts.values().flat_map(|p| p.blocks.iter()).map(|b| if let Block::Para(p) = &**b { p.objects.iter().map(xml).sum() } else { 0 }).sum();
|
||||
assert!((1..64).contains(&added), "{added}");
|
||||
assert!(copied <= MAX_PASTED_BOX_BYTES, "{copied}");
|
||||
// Nested group members and equations count too.
|
||||
let member = |obj| crate::para::GroupChild { x: 0.0, y: 0.0, obj };
|
||||
let deep = InlineObject::Opaque { format: String::new(), xml: "y".repeat(5000), text: String::new() };
|
||||
let inner =
|
||||
InlineObject::Group { w: 1.0, h: 1.0, float: Default::default(), ch_w: 1.0, ch_h: 1.0, children: vec![member(deep)], canvas: None };
|
||||
let outer =
|
||||
InlineObject::Group { w: 1.0, h: 1.0, float: Default::default(), ch_w: 1.0, ch_h: 1.0, children: vec![member(inner)], canvas: None };
|
||||
assert!(object_cost(&outer) >= 5000);
|
||||
let eq = InlineObject::Equation {
|
||||
linear: String::new(),
|
||||
display: false,
|
||||
math: crate::math::Math { nodes: crate::math::parse_linear(&"z".repeat(5000)), ..Default::default() },
|
||||
};
|
||||
assert!(object_cost(&eq) >= 5000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -43,12 +43,12 @@ fn scan(story: StoryRef, bl: &Blocks, prefix: &mut Vec<u32>, depth: usize, out:
|
||||
match &**b {
|
||||
Block::Para(p) => {
|
||||
let path = Path(prefix.clone());
|
||||
for off in p.object_offsets() {
|
||||
match p.object_at(off) {
|
||||
Some(InlineObject::FieldStart { instr, locked, .. }) => {
|
||||
for (off, o) in p.objects_with_offsets() {
|
||||
match o {
|
||||
InlineObject::FieldStart { instr, locked, .. } => {
|
||||
open.push((instr.clone(), *locked, Pos::new(story, path.clone(), off)));
|
||||
}
|
||||
Some(InlineObject::FieldEnd) => match open.pop() {
|
||||
InlineObject::FieldEnd => match open.pop() {
|
||||
Some((instr, locked, start)) => {
|
||||
let depth = open.len();
|
||||
out.ranges.push(FieldRange { instr, locked, start, end: Pos::new(story, path.clone(), off), depth });
|
||||
@@ -99,7 +99,10 @@ impl Document {
|
||||
pub fn past_field_ends(&self, p: &Pos) -> Pos {
|
||||
let Some(para) = self.para_at(p) else { return p.clone() };
|
||||
let mut off = p.off;
|
||||
while matches!(para.object_at(off), Some(InlineObject::FieldEnd)) {
|
||||
for (at, o) in para.objects_with_offsets().skip_while(|(at, _)| *at < p.off) {
|
||||
if at != off || !matches!(o, InlineObject::FieldEnd) {
|
||||
break;
|
||||
}
|
||||
off += OBJ.len_utf8();
|
||||
}
|
||||
Pos { off, ..p.clone() }
|
||||
|
||||
@@ -0,0 +1,712 @@
|
||||
//! Visible logical text for IME surrounding-text queries.
|
||||
//!
|
||||
//! The platform input method works on what the reader sees, not on the stored run text: tracked
|
||||
//! deletions are hidden, and an inline object stands for its plain-text result. The helpers here
|
||||
//! build a bounded window around a selection without cloning the paragraph or the document, and
|
||||
//! map both ends in both directions so a query and a delete use exactly the same text.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use crate::para::{OBJ, Paragraph};
|
||||
use crate::{Document, Pos};
|
||||
|
||||
/// A bounded visible-logical window around a selection.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ImeWindow {
|
||||
/// At most [`MAX_IME_TEXT_BYTES`] bytes of visible committed text.
|
||||
pub text: String,
|
||||
/// UTF-8 byte offset of the primary end in [`Self::text`].
|
||||
pub cursor: usize,
|
||||
/// UTF-8 byte offset of the other end in [`Self::text`].
|
||||
pub anchor: usize,
|
||||
}
|
||||
|
||||
/// The largest visible window an IME query copies.
|
||||
pub const MAX_IME_TEXT_BYTES: usize = 4000;
|
||||
|
||||
/// Which unit an input method's delete-surrounding counts use.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DeleteUnit {
|
||||
/// Unicode scalar values of the visible text.
|
||||
Chars,
|
||||
/// UTF-8 bytes of the visible text.
|
||||
Bytes,
|
||||
}
|
||||
|
||||
/// A deletion request in the paragraph's stored byte offsets.
|
||||
///
|
||||
/// The selection itself is never part of `before` or `after`; both ranges are outside it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ImeDelete {
|
||||
/// Stored byte range to erase before the selection.
|
||||
pub before: Option<Range<usize>>,
|
||||
/// Stored byte range to erase after the selection.
|
||||
pub after: Option<Range<usize>>,
|
||||
/// Stored byte offset of the selection's anchor **before** the deletions.
|
||||
///
|
||||
/// The ranges are returned in the paragraph's stored text as it is now; the caller applies
|
||||
/// them (and shifts these offsets past `before`) itself.
|
||||
pub anchor: usize,
|
||||
/// Stored byte offset of the selection's focus **before** the deletions.
|
||||
pub focus: usize,
|
||||
}
|
||||
|
||||
/// One paragraph's visible logical text.
|
||||
///
|
||||
/// Deleted runs are skipped and inline objects contribute their plain-text result. Iteration is
|
||||
/// lazy: no full visible copy is made.
|
||||
pub struct VisibleParagraph<'a> {
|
||||
para: &'a Paragraph,
|
||||
deleted: Vec<Range<usize>>,
|
||||
}
|
||||
|
||||
impl<'a> VisibleParagraph<'a> {
|
||||
/// A view of `para` with tracked deletions hidden.
|
||||
pub fn new(para: &'a Paragraph) -> Self {
|
||||
let mut deleted = para.deleted_ranges();
|
||||
deleted.sort_unstable_by_key(|r| r.start);
|
||||
Self { para, deleted }
|
||||
}
|
||||
|
||||
/// Whether `off` falls inside a tracked deletion, advancing `next` past the runs that end
|
||||
/// before it.
|
||||
///
|
||||
/// The ranges are sorted, so one monotone cursor makes a whole scan O(text + ranges) instead
|
||||
/// of O(text × ranges): alternating tracked revisions used to make every character scan every
|
||||
/// range.
|
||||
fn is_deleted_at(&self, next: &mut usize, off: usize) -> bool {
|
||||
while let Some(r) = self.deleted.get(*next) {
|
||||
if r.end > off {
|
||||
break;
|
||||
}
|
||||
*next = next.saturating_add(1);
|
||||
}
|
||||
self.deleted.get(*next).is_some_and(|r| r.start <= off && off < r.end)
|
||||
}
|
||||
|
||||
/// Call `f` for every visible piece: `(stored byte start, piece, stored byte len)`.
|
||||
fn for_each_piece(&self, mut f: impl FnMut(usize, &str, usize)) {
|
||||
self.for_each_piece_while(|start, piece, stored| {
|
||||
f(start, piece, stored);
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
/// Call `f` for visible pieces until it returns `false`.
|
||||
fn for_each_piece_while(&self, mut f: impl FnMut(usize, &str, usize) -> bool) {
|
||||
let mut object = 0usize;
|
||||
let mut deleted = 0usize;
|
||||
for (i, c) in self.para.text.char_indices() {
|
||||
let is_object = c == OBJ;
|
||||
if is_object {
|
||||
object += 1;
|
||||
}
|
||||
if self.is_deleted_at(&mut deleted, i) {
|
||||
continue;
|
||||
}
|
||||
let keep_going = if is_object {
|
||||
let piece = self.para.objects.get(object - 1).map_or("", |o| o.plain_text());
|
||||
f(i, piece, OBJ.len_utf8())
|
||||
} else {
|
||||
let mut buf = [0u8; 4];
|
||||
f(i, c.encode_utf8(&mut buf), c.len_utf8())
|
||||
};
|
||||
if !keep_going {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Total visible UTF-8 bytes.
|
||||
pub fn visible_len(&self) -> usize {
|
||||
let mut len = 0usize;
|
||||
self.for_each_piece(|_, piece, _| len = len.saturating_add(piece.len()));
|
||||
len
|
||||
}
|
||||
|
||||
/// Visible offset of a stored paragraph byte offset.
|
||||
///
|
||||
/// `None` when the stored offset is not a character boundary, points inside a tracked
|
||||
/// deletion, or would split an inline object's plain-text result.
|
||||
pub fn visible_offset(&self, stored: usize) -> Option<usize> {
|
||||
if stored > self.para.text.len() || !self.para.text.is_char_boundary(stored) {
|
||||
return None;
|
||||
}
|
||||
let mut visible = 0usize;
|
||||
let mut found = false;
|
||||
let mut object = 0usize;
|
||||
let mut deleted = 0usize;
|
||||
for (i, c) in self.para.text.char_indices() {
|
||||
if i == stored {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
if i > stored {
|
||||
break;
|
||||
}
|
||||
let is_object = c == OBJ;
|
||||
if is_object {
|
||||
object += 1;
|
||||
}
|
||||
if self.is_deleted_at(&mut deleted, i) {
|
||||
// A position at the start of a deleted run is still a visible position; one
|
||||
// inside it is not.
|
||||
continue;
|
||||
}
|
||||
if is_object {
|
||||
let piece = self.para.objects.get(object - 1).map_or("", |o| o.plain_text());
|
||||
visible = visible.saturating_add(piece.len());
|
||||
} else {
|
||||
visible = visible.saturating_add(c.len_utf8());
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
// The end of the paragraph, or a deleted run at its end.
|
||||
let mut next = 0usize;
|
||||
return (!self.is_deleted_at(&mut next, stored)).then_some(visible);
|
||||
}
|
||||
if stored < self.para.text.len() && self.is_deleted_at(&mut deleted, stored) {
|
||||
// The start of a deleted run is still a visible caret position; only a position
|
||||
// strictly inside one is unrepresentable.
|
||||
let at_start = self.deleted.iter().any(|r| r.start == stored);
|
||||
return at_start.then_some(visible);
|
||||
}
|
||||
Some(visible)
|
||||
}
|
||||
|
||||
/// Stored paragraph byte offset for a visible offset.
|
||||
///
|
||||
/// `None` when the visible offset falls inside an inline object's plain-text result or is
|
||||
/// not a boundary of the visible representation.
|
||||
pub fn stored_offset(&self, visible: usize) -> Option<usize> {
|
||||
let mut acc = 0usize;
|
||||
let mut answer = None;
|
||||
let mut object = 0usize;
|
||||
let mut deleted = 0usize;
|
||||
for (i, c) in self.para.text.char_indices() {
|
||||
let is_object = c == OBJ;
|
||||
if is_object {
|
||||
object += 1;
|
||||
}
|
||||
if self.is_deleted_at(&mut deleted, i) {
|
||||
continue;
|
||||
}
|
||||
let piece_len = if is_object { self.para.objects.get(object - 1).map_or(0, |o| o.plain_text().len()) } else { c.len_utf8() };
|
||||
if visible == acc {
|
||||
answer = Some(i);
|
||||
break;
|
||||
}
|
||||
if visible < acc.saturating_add(piece_len) {
|
||||
// Inside a character (or an object's expansion): no stored boundary there.
|
||||
return None;
|
||||
}
|
||||
acc = acc.saturating_add(piece_len);
|
||||
}
|
||||
answer.or_else(|| (visible == acc).then_some(self.para.text.len()))
|
||||
}
|
||||
|
||||
/// Stored offset for the **start** of a visible range, skipping zero-length objects at
|
||||
/// the same boundary.
|
||||
///
|
||||
/// A content control's markers are inline objects with no plain text, so they share a
|
||||
/// visible offset with the content just inside (or after) them. A range start must map
|
||||
/// after a control-start marker and after a control-end marker, never onto the marker
|
||||
/// itself: an IME deletion must not break the control. Non-empty object expansions still
|
||||
/// map to the object's own stored offset, so a range that reaches one deletes it whole.
|
||||
pub fn stored_offset_after(&self, visible: usize) -> Option<usize> {
|
||||
let mut acc = 0usize;
|
||||
let mut object = 0usize;
|
||||
let mut deleted = 0usize;
|
||||
for (i, c) in self.para.text.char_indices() {
|
||||
let is_object = c == OBJ;
|
||||
if is_object {
|
||||
object += 1;
|
||||
}
|
||||
if self.is_deleted_at(&mut deleted, i) {
|
||||
continue;
|
||||
}
|
||||
let piece_len = if is_object { self.para.objects.get(object - 1).map_or(0, |o| o.plain_text().len()) } else { c.len_utf8() };
|
||||
if visible == acc && piece_len > 0 {
|
||||
return Some(i);
|
||||
}
|
||||
if visible < acc.saturating_add(piece_len) {
|
||||
return None;
|
||||
}
|
||||
acc = acc.saturating_add(piece_len);
|
||||
}
|
||||
(visible == acc).then_some(self.para.text.len())
|
||||
}
|
||||
|
||||
/// Stored offset for a visible offset, preferring the position **before** a tracked
|
||||
/// deletion that starts there. Use this for the end of a range so a deletion run just
|
||||
/// after the caret is never drawn into it.
|
||||
pub fn stored_offset_before(&self, visible: usize) -> Option<usize> {
|
||||
let mut acc = 0usize;
|
||||
let mut object = 0usize;
|
||||
let mut deleted = 0usize;
|
||||
for (i, c) in self.para.text.char_indices() {
|
||||
if visible == acc {
|
||||
return Some(i);
|
||||
}
|
||||
let is_object = c == OBJ;
|
||||
if is_object {
|
||||
object += 1;
|
||||
}
|
||||
if self.is_deleted_at(&mut deleted, i) {
|
||||
continue;
|
||||
}
|
||||
let piece_len = if is_object { self.para.objects.get(object - 1).map_or(0, |o| o.plain_text().len()) } else { c.len_utf8() };
|
||||
if visible < acc.saturating_add(piece_len) {
|
||||
return None;
|
||||
}
|
||||
acc = acc.saturating_add(piece_len);
|
||||
}
|
||||
(visible == acc).then_some(self.para.text.len())
|
||||
}
|
||||
|
||||
/// Round a visible byte offset forward to the next visible boundary (inward for a start).
|
||||
pub fn ceil_boundary(&self, visible: usize) -> usize {
|
||||
let mut acc = 0usize;
|
||||
let mut out = None;
|
||||
self.for_each_piece(|_, piece, _| {
|
||||
if out.is_some() {
|
||||
return;
|
||||
}
|
||||
if acc >= visible {
|
||||
out = Some(acc);
|
||||
return;
|
||||
}
|
||||
acc = acc.saturating_add(piece.len());
|
||||
if acc >= visible {
|
||||
out = Some(acc);
|
||||
}
|
||||
});
|
||||
out.unwrap_or(acc)
|
||||
}
|
||||
|
||||
/// Round a visible byte offset backward to the previous visible boundary (inward for an end).
|
||||
pub fn floor_boundary(&self, visible: usize) -> usize {
|
||||
let mut acc = 0usize;
|
||||
let mut out = 0usize;
|
||||
self.for_each_piece(|_, piece, _| {
|
||||
let next = acc.saturating_add(piece.len());
|
||||
if next > visible {
|
||||
return;
|
||||
}
|
||||
acc = next;
|
||||
out = acc;
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// A bounded window with `cursor` and `anchor` as visible byte offsets.
|
||||
///
|
||||
/// The selection is never split and the window never exceeds [`MAX_IME_TEXT_BYTES`]. A
|
||||
/// selection wider than that, an invalid offset, or an offset that cannot be represented as
|
||||
/// visible text returns `None` (unavailable, not fabricated).
|
||||
pub fn window(&self, cursor: usize, anchor: usize, max_bytes: usize) -> Option<ImeWindow> {
|
||||
// The public API caps its own budget: a caller cannot ask for an unbounded copy.
|
||||
let max_bytes = max_bytes.min(MAX_IME_TEXT_BYTES);
|
||||
let (sel_start, sel_end) = (cursor.min(anchor), cursor.max(anchor));
|
||||
let selection_len = sel_end - sel_start;
|
||||
if selection_len > max_bytes {
|
||||
return None;
|
||||
}
|
||||
// Spend the budget around the selection and stop walking at its end: the paragraph is
|
||||
// never copied and the work is bounded by the caret's distance from the paragraph start.
|
||||
let before = (max_bytes - selection_len) / 2;
|
||||
let window_start = sel_start.saturating_sub(before);
|
||||
let window_end = window_start.saturating_add(max_bytes);
|
||||
let mut text = String::with_capacity(window_end.saturating_sub(window_start).min(max_bytes));
|
||||
let mut first_kept = None;
|
||||
let mut acc = 0usize;
|
||||
self.for_each_piece_while(|_, piece, _| {
|
||||
for (k, c) in piece.char_indices() {
|
||||
let start = acc.saturating_add(k);
|
||||
let end = start.saturating_add(c.len_utf8());
|
||||
if end > window_end {
|
||||
return false;
|
||||
}
|
||||
if start >= window_start {
|
||||
if first_kept.is_none() {
|
||||
first_kept = Some(start);
|
||||
}
|
||||
text.push(c);
|
||||
}
|
||||
}
|
||||
acc = acc.saturating_add(piece.len());
|
||||
acc < window_end
|
||||
});
|
||||
let Some(first) = first_kept else {
|
||||
// An empty document is an available empty context, never unavailable.
|
||||
return (cursor == 0 && anchor == 0).then_some(ImeWindow { text: String::new(), cursor: 0, anchor: 0 });
|
||||
};
|
||||
Some(ImeWindow { text, cursor: cursor.saturating_sub(first), anchor: anchor.saturating_sub(first) })
|
||||
}
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Bounded visible logical text around `cursor`/`anchor` in one paragraph.
|
||||
///
|
||||
/// A selection across paragraphs or blocks is unrepresentable and returns `None`.
|
||||
pub fn ime_surrounding(&self, cursor: &Pos, anchor: &Pos, max_bytes: usize) -> Option<ImeWindow> {
|
||||
if cursor.story != anchor.story || cursor.path != anchor.path {
|
||||
return None;
|
||||
}
|
||||
let para = self.para_at(cursor)?;
|
||||
let view = VisibleParagraph::new(para);
|
||||
let c = view.visible_offset(cursor.off)?;
|
||||
let a = view.visible_offset(anchor.off)?;
|
||||
view.window(c, a, max_bytes.min(MAX_IME_TEXT_BYTES))
|
||||
}
|
||||
|
||||
/// Delete-surrounding ranges in the stored paragraph text.
|
||||
///
|
||||
/// Counts exclude the selection, tracked deletions and any pre-edit (which never lives in
|
||||
/// the model here). Byte counts that end inside a scalar leave it whole: a start rounds
|
||||
/// forward and an end rounds backward; a byte count beyond the visible text is refused.
|
||||
/// Character counts clamp at the paragraph's ends, as the web text agent's do. A range that
|
||||
/// cannot be represented in stored text returns `None` instead of guessing; so does a
|
||||
/// request with nothing to erase (`0` on both sides).
|
||||
pub fn ime_delete_surrounding(&self, cursor: &Pos, anchor: &Pos, before: usize, after: usize, unit: DeleteUnit) -> Option<ImeDelete> {
|
||||
if cursor.story != anchor.story || cursor.path != anchor.path {
|
||||
return None;
|
||||
}
|
||||
if before == 0 && after == 0 {
|
||||
// Nothing to erase: don't build the boundary table (or walk a long paragraph) to
|
||||
// discover it.
|
||||
return None;
|
||||
}
|
||||
let para = self.para_at(cursor)?;
|
||||
let view = VisibleParagraph::new(para);
|
||||
let c = view.visible_offset(cursor.off)?;
|
||||
let a = view.visible_offset(anchor.off)?;
|
||||
let (sel_start, sel_end) = (c.min(a), c.max(a));
|
||||
|
||||
let (before_start, after_end) = match unit {
|
||||
DeleteUnit::Bytes => {
|
||||
let total = view.visible_len();
|
||||
if before > sel_start || after > total - sel_end {
|
||||
return None;
|
||||
}
|
||||
(view.ceil_boundary(sel_start.saturating_sub(before)), view.floor_boundary(sel_end.saturating_add(after)))
|
||||
}
|
||||
DeleteUnit::Chars => char_boundaries(&view, sel_start, sel_end, before, after)?,
|
||||
};
|
||||
|
||||
// Start a range after a deletion run and end it before one, so tracked deletions are
|
||||
// never part of the erased stored range.
|
||||
let before_start = view.stored_offset_after(before_start)?;
|
||||
let before_end = view.stored_offset_before(sel_start)?;
|
||||
let after_start = view.stored_offset_after(sel_end)?;
|
||||
let after_end = view.stored_offset_before(after_end)?;
|
||||
let before_range = (before_start < before_end).then_some(before_start..before_end);
|
||||
let after_range = (after_start < after_end).then_some(after_start..after_end);
|
||||
Some(ImeDelete { before: before_range, after: after_range, anchor: anchor.off, focus: cursor.off })
|
||||
}
|
||||
}
|
||||
|
||||
/// The visible byte boundaries `before` characters back from `sel_start` and `after`
|
||||
/// characters on from `sel_end`.
|
||||
///
|
||||
/// Two walks and constant memory replace the old per-character `Vec` that held every boundary
|
||||
/// (a hostile count could make it allocate the whole paragraph). Character counts clamp at the
|
||||
/// paragraph's ends; `None` when either selection end is not a boundary of the visible text.
|
||||
fn char_boundaries(view: &VisibleParagraph<'_>, sel_start: usize, sel_end: usize, before: usize, after: usize) -> Option<(usize, usize)> {
|
||||
let mut acc = 0usize;
|
||||
let mut chars = 0usize;
|
||||
let mut start_index = None;
|
||||
let mut end_index = None;
|
||||
view.for_each_piece(|_, piece, _| {
|
||||
for (k, _) in piece.char_indices() {
|
||||
let at = acc.saturating_add(k);
|
||||
if at == sel_start {
|
||||
start_index = Some(chars);
|
||||
}
|
||||
if at == sel_end {
|
||||
end_index = Some(chars);
|
||||
}
|
||||
chars = chars.saturating_add(1);
|
||||
}
|
||||
acc = acc.saturating_add(piece.len());
|
||||
});
|
||||
if sel_start == acc {
|
||||
start_index = Some(chars);
|
||||
}
|
||||
if sel_end == acc {
|
||||
end_index = Some(chars);
|
||||
}
|
||||
let start_index = start_index?;
|
||||
let end_index = end_index?;
|
||||
let target_before = start_index.saturating_sub(before);
|
||||
let target_after = end_index.saturating_add(after).min(chars);
|
||||
|
||||
let mut acc = 0usize;
|
||||
let mut index = 0usize;
|
||||
let mut before_start = None;
|
||||
let mut after_end = (target_after == 0).then_some(0);
|
||||
view.for_each_piece(|_, piece, _| {
|
||||
for (k, _) in piece.char_indices() {
|
||||
let at = acc.saturating_add(k);
|
||||
if index == target_before && before_start.is_none() {
|
||||
before_start = Some(at);
|
||||
}
|
||||
if index == target_after && after_end.is_none() {
|
||||
after_end = Some(at);
|
||||
}
|
||||
index = index.saturating_add(1);
|
||||
}
|
||||
acc = acc.saturating_add(piece.len());
|
||||
});
|
||||
if index == target_before && before_start.is_none() {
|
||||
before_start = Some(acc);
|
||||
}
|
||||
if index == target_after && after_end.is_none() {
|
||||
after_end = Some(acc);
|
||||
}
|
||||
Some((before_start?, after_end?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::para::Run;
|
||||
use crate::props::CharProps;
|
||||
use crate::{InlineObject, StoryRef};
|
||||
|
||||
/// Builds a paragraph with one run per `(text, deleted)` part; deleted parts carry revision 0.
|
||||
fn para_with_runs(parts: &[(&str, bool)]) -> Paragraph {
|
||||
let mut p = Paragraph::new();
|
||||
for (text, deleted) in parts {
|
||||
let mut props = CharProps::default();
|
||||
if *deleted {
|
||||
props.del = Some(0);
|
||||
}
|
||||
p.text.push_str(text);
|
||||
p.runs.push(Run { len: text.len(), props });
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// A tracked deletion is hidden from the IME view but still maps stored offsets: its start is a
|
||||
/// visible caret position, its interior is not, and a window concatenates only visible text.
|
||||
#[test]
|
||||
fn tracked_deletions_are_hidden_and_offsets_still_map() {
|
||||
let p = para_with_runs(&[("ab", false), ("DEL", true), ("cd", false)]);
|
||||
let view = VisibleParagraph::new(&p);
|
||||
assert_eq!(view.visible_len(), 4);
|
||||
assert_eq!(view.visible_offset(0), Some(0));
|
||||
assert_eq!(view.visible_offset(2), Some(2), "the start of a deleted run is a visible caret position");
|
||||
assert_eq!(view.visible_offset(3), None, "inside a tracked deletion");
|
||||
assert_eq!(view.visible_offset(5), Some(2));
|
||||
assert_eq!(view.visible_offset(7), Some(4));
|
||||
assert_eq!(view.stored_offset(2), Some(5));
|
||||
assert_eq!(view.stored_offset(4), Some(7));
|
||||
assert_eq!(view.window(2, 2, 4000).map(|w| w.text), Some("abcd".into()));
|
||||
}
|
||||
|
||||
/// An inline object contributes its plain text to visible length and boundary mapping; offsets
|
||||
/// strictly inside that expansion have no stored boundary.
|
||||
#[test]
|
||||
fn objects_contribute_their_plain_text_and_split_mapping_stops() {
|
||||
let mut p = Paragraph::new();
|
||||
p.text = format!("a{OBJ}b");
|
||||
p.runs = vec![Run { len: p.text.len(), props: CharProps::default() }];
|
||||
p.objects.push(InlineObject::Equation { linear: "x+y".into(), display: false, math: Default::default() });
|
||||
let view = VisibleParagraph::new(&p);
|
||||
assert_eq!(view.visible_len(), 5);
|
||||
assert_eq!(view.visible_offset(1), Some(1));
|
||||
assert_eq!(view.visible_offset(4), Some(4));
|
||||
assert_eq!(view.stored_offset(1), Some(1));
|
||||
assert_eq!(view.stored_offset(4), Some(4));
|
||||
assert_eq!(view.stored_offset(2), None, "inside the object's text");
|
||||
}
|
||||
|
||||
/// An empty paragraph yields an available empty IME context at offset 0; an empty document
|
||||
/// yields an available empty context too, though this test asserts only its text, not its offsets.
|
||||
#[test]
|
||||
fn an_empty_document_is_available_and_empty() {
|
||||
let p = Paragraph::new();
|
||||
let view = VisibleParagraph::new(&p);
|
||||
let w = view.window(0, 0, MAX_IME_TEXT_BYTES).expect("an available empty context");
|
||||
assert_eq!((w.text.as_str(), w.cursor, w.anchor), ("", 0, 0));
|
||||
let doc = Document::new();
|
||||
let w = doc.ime_surrounding(&Pos::body(0, 0), &Pos::body(0, 0), MAX_IME_TEXT_BYTES).expect("available");
|
||||
assert_eq!(w.text, "");
|
||||
}
|
||||
|
||||
/// A window is capped at the IME byte budget while keeping cursor and anchor inside its text; a
|
||||
/// selection wider than the budget is unavailable.
|
||||
#[test]
|
||||
fn bounded_window_keeps_the_selection_and_rejects_too_wide_ones() {
|
||||
let p = Paragraph::with_text(&"a".repeat(10_000), CharProps::default());
|
||||
let view = VisibleParagraph::new(&p);
|
||||
let w = view.window(5000, 5000, MAX_IME_TEXT_BYTES).expect("a bounded window");
|
||||
assert!(w.text.len() <= MAX_IME_TEXT_BYTES);
|
||||
assert_eq!(w.cursor, w.anchor);
|
||||
assert!(w.cursor <= w.text.len());
|
||||
assert!(view.window(0, 4001, MAX_IME_TEXT_BYTES).is_none());
|
||||
}
|
||||
|
||||
/// Byte deletion rounds its start to a visible UTF-8 boundary, leaves partially covered scalar
|
||||
/// values whole, and refuses counts that run past the paragraph.
|
||||
#[test]
|
||||
fn byte_deletion_rounds_inward_and_leaves_partial_scalars_whole() {
|
||||
let doc = Document::from_text("aé👍b");
|
||||
let caret = Pos::body(0, 8);
|
||||
let one = doc.ime_delete_surrounding(&caret, &caret, 1, 0, DeleteUnit::Bytes).expect("b");
|
||||
assert_eq!(one.before, Some(7..8));
|
||||
// Two bytes start inside the emoji: it is left whole, only `b` goes.
|
||||
let two = doc.ime_delete_surrounding(&caret, &caret, 2, 0, DeleteUnit::Bytes).expect("b");
|
||||
assert_eq!(two.before, Some(7..8));
|
||||
// Six bytes start inside `é`: it stays whole, the emoji and `b` go.
|
||||
let six = doc.ime_delete_surrounding(&caret, &caret, 6, 0, DeleteUnit::Bytes).expect("emoji and b");
|
||||
assert_eq!(six.before, Some(3..8));
|
||||
// A count beyond the paragraph is refused, not clipped to delete a whole side.
|
||||
assert!(doc.ime_delete_surrounding(&caret, &caret, 0, 1, DeleteUnit::Bytes).is_none());
|
||||
assert!(doc.ime_delete_surrounding(&caret, &caret, 99, 0, DeleteUnit::Bytes).is_none());
|
||||
}
|
||||
|
||||
/// Surrounding deletion skips the selected range on both sides and returns the selection's
|
||||
/// original reversed anchor and focus.
|
||||
#[test]
|
||||
fn byte_deletion_excludes_a_reversed_selection() {
|
||||
let doc = Document::from_text("abcd");
|
||||
let cursor = Pos::body(0, 1);
|
||||
let anchor = Pos::body(0, 3);
|
||||
let del = doc.ime_delete_surrounding(&cursor, &anchor, 1, 1, DeleteUnit::Bytes).expect("a deletion");
|
||||
assert_eq!(del.before, Some(0..1), "only the character before the selection");
|
||||
assert_eq!(del.after, Some(3..4), "only the character after the selection");
|
||||
assert_eq!((del.anchor, del.focus), (3, 1), "the reversed selection is preserved");
|
||||
}
|
||||
|
||||
/// Tracked deletions are absent from windows and skipped when deleting around a caret, so an IME
|
||||
/// edit cannot remove them.
|
||||
#[test]
|
||||
fn tracked_deletions_are_not_counted_for_deletion_or_query() {
|
||||
let mut doc = Document::from_text("abDELcd");
|
||||
let p = para_with_runs(&[("ab", false), ("DEL", true), ("cd", false)]);
|
||||
doc.body = vec![std::sync::Arc::new(crate::Block::Para(p))];
|
||||
let view = VisibleParagraph::new(doc.para_at(&Pos::body(0, 0)).expect("paragraph"));
|
||||
assert_eq!(view.window(2, 2, 100).map(|w| w.text), Some("abcd".into()));
|
||||
let caret = Pos::body(0, 7);
|
||||
let del = doc.ime_delete_surrounding(&caret, &caret, 1, 0, DeleteUnit::Bytes).expect("c");
|
||||
assert_eq!(del.before, Some(6..7), "the stored deletion run is skipped");
|
||||
}
|
||||
|
||||
/// A selection spanning paragraphs has no single surrounding context: both the query and
|
||||
/// surrounding deletion return `None`.
|
||||
#[test]
|
||||
fn cross_paragraph_selection_is_unavailable() {
|
||||
let doc = Document::from_text("one\ntwo");
|
||||
let cursor = Pos::body(0, 1);
|
||||
let anchor = Pos::body(1, 1);
|
||||
assert!(doc.ime_surrounding(&cursor, &anchor, MAX_IME_TEXT_BYTES).is_none());
|
||||
assert!(doc.ime_delete_surrounding(&cursor, &anchor, 1, 0, DeleteUnit::Chars).is_none());
|
||||
}
|
||||
|
||||
/// An object's plain-text result stands for the object in the visible text; a deletion
|
||||
/// that starts inside that expansion leaves the object whole (byte) or is refused (chars).
|
||||
#[test]
|
||||
fn object_expansion_is_never_partly_deleted() {
|
||||
let mut doc = Document::from_text("");
|
||||
let mut p = Paragraph::new();
|
||||
p.text = format!("a{OBJ}b");
|
||||
p.runs = vec![Run { len: p.text.len(), props: CharProps::default() }];
|
||||
p.objects.push(InlineObject::Equation { linear: "x+y".into(), display: false, math: Default::default() });
|
||||
doc.body = vec![std::sync::Arc::new(crate::Block::Para(p))];
|
||||
let caret = Pos::body(0, 5);
|
||||
// Two bytes before the caret end inside `x+y` (`y` is the last byte): the start rounds
|
||||
// forward past the object, so only `b` goes.
|
||||
let two = doc.ime_delete_surrounding(&caret, &caret, 2, 0, DeleteUnit::Bytes).expect("b only");
|
||||
assert_eq!(two.before, Some(4..5), "stored UTF-8 offsets keep the object whole");
|
||||
let mut remaining = format!("a{OBJ}b");
|
||||
remaining.replace_range(two.before.clone().unwrap(), "");
|
||||
assert_eq!(remaining, format!("a{OBJ}"));
|
||||
// A character that lands inside the expansion has no stored boundary: refused.
|
||||
assert!(doc.ime_delete_surrounding(&caret, &caret, 2, 0, DeleteUnit::Chars).is_none());
|
||||
// A count reaching before the object erases the object as a whole.
|
||||
let five = doc.ime_delete_surrounding(&caret, &caret, 5, 0, DeleteUnit::Bytes).expect("object and b");
|
||||
assert_eq!(five.before, Some(0..5));
|
||||
}
|
||||
|
||||
/// Character counts on the right side clamp at the paragraph's end and never cross into the
|
||||
/// next paragraph.
|
||||
#[test]
|
||||
fn trailing_character_deletion_clamps_at_the_paragraph_end() {
|
||||
let doc = Document::from_text("ab\ncd");
|
||||
let caret = Pos::body(0, 1);
|
||||
let del = doc.ime_delete_surrounding(&caret, &caret, 0, 99, DeleteUnit::Chars).expect("b only");
|
||||
assert_eq!(del.after, Some(1..2));
|
||||
let empty = doc.ime_delete_surrounding(&caret, &caret, 0, 0, DeleteUnit::Chars);
|
||||
assert!(empty.is_none(), "nothing to erase");
|
||||
}
|
||||
|
||||
/// Sorted tracked deletions are scanned with one cursor: alternating revisions stay correct
|
||||
/// (and linear) instead of each character scanning every range.
|
||||
#[test]
|
||||
fn alternating_tracked_revisions_map_correctly() {
|
||||
// Equal-length visible/deleted pieces, so the expected visible byte count is exact.
|
||||
let parts: Vec<(String, bool)> =
|
||||
(0..2000).map(|i| (if i % 2 == 0 { format!("v{i:04};") } else { format!("D{i:04};") }, i % 2 == 1)).collect();
|
||||
let parts: Vec<(&str, bool)> = parts.iter().map(|(t, d)| (t.as_str(), *d)).collect();
|
||||
let p = para_with_runs(&parts);
|
||||
let view = VisibleParagraph::new(&p);
|
||||
assert_eq!(view.visible_len(), p.text.len() - 6 * 1000);
|
||||
assert_eq!(view.visible_offset(p.text.len()), Some(view.visible_len()));
|
||||
let w = view.window(0, 0, 100).expect("a window");
|
||||
assert!(w.text.len() <= 100);
|
||||
assert!(!w.text.contains('D'), "tracked deletions never show");
|
||||
}
|
||||
|
||||
/// The public window never copies more than the IME cap, whatever a caller asks for.
|
||||
#[test]
|
||||
fn window_budget_is_capped() {
|
||||
let p = Paragraph::with_text(&"a".repeat(10_000), CharProps::default());
|
||||
let view = VisibleParagraph::new(&p);
|
||||
let w = view.window(0, 0, usize::MAX).expect("a bounded window");
|
||||
assert_eq!(w.text.len(), MAX_IME_TEXT_BYTES);
|
||||
let doc = Document::from_text(&"a".repeat(10_000));
|
||||
let w = doc.ime_surrounding(&Pos::body(0, 0), &Pos::body(0, 0), usize::MAX).expect("bounded");
|
||||
assert_eq!(w.text.len(), MAX_IME_TEXT_BYTES);
|
||||
}
|
||||
|
||||
/// A caret in a table-cell paragraph maps against that cell's text, not the body around the table.
|
||||
#[test]
|
||||
fn table_cell_paragraph_maps_inside_its_cell() {
|
||||
let mut doc = Document::from_text("outside");
|
||||
let mut table = crate::Table::new(1, 1, 100.0);
|
||||
table.rows[0].cells[0].blocks = vec![crate::para_block(Paragraph::with_text("cell", CharProps::default()))];
|
||||
doc.body.push(std::sync::Arc::new(crate::Block::Table(table)));
|
||||
let path = crate::Path(vec![1, 0, 0, 0]);
|
||||
let cursor = Pos { story: StoryRef::Body, path: path.clone(), off: 4 };
|
||||
let anchor = Pos { story: StoryRef::Body, path, off: 2 };
|
||||
let w = doc.ime_surrounding(&cursor, &anchor, MAX_IME_TEXT_BYTES).expect("the cell text");
|
||||
assert_eq!(w.text, "cell");
|
||||
assert_eq!(w.cursor, 4);
|
||||
assert_eq!(w.anchor, 2);
|
||||
}
|
||||
|
||||
/// Content-control markers are zero-length visible objects. A deletion range starts after
|
||||
/// the marker and ends before the next one, so an ordinary IME edit never removes a marker
|
||||
/// or leaves a half-paired control.
|
||||
#[test]
|
||||
fn control_markers_are_not_included_in_delete_ranges() {
|
||||
use crate::control::{ContentControl, ControlKind};
|
||||
let mut p = Paragraph::new();
|
||||
p.text = format!("pre{OBJ}Ada{OBJ}X");
|
||||
p.runs = vec![Run { len: p.text.len(), props: CharProps::default() }];
|
||||
let mut ctl = ContentControl::new(ControlKind::RichText);
|
||||
ctl.placeholder_text = "Enter name".into();
|
||||
p.objects.push(InlineObject::ControlStart { control: Box::new(ctl) });
|
||||
p.objects.push(InlineObject::ControlEnd);
|
||||
let mut doc = Document::new();
|
||||
doc.body = vec![std::sync::Arc::new(crate::Block::Para(p))];
|
||||
// Stored positions are UTF-8 byte offsets; each OBJ marker occupies three bytes.
|
||||
let cursor = Pos::body(0, 9);
|
||||
let del = doc.ime_delete_surrounding(&cursor, &cursor, 3, 1, DeleteUnit::Bytes).expect("a deletion");
|
||||
assert_eq!(del.before, Some(6..9), "the control-start marker and `pre` stay outside");
|
||||
assert_eq!(del.after, Some(12..13), "the control-end marker stays outside; only `X` goes");
|
||||
assert_eq!((del.anchor, del.focus), (9, 9));
|
||||
}
|
||||
}
|
||||
+94
-23
@@ -12,6 +12,7 @@
|
||||
|
||||
pub mod bidi;
|
||||
mod byte_store;
|
||||
pub mod cfb_guard;
|
||||
pub mod chart;
|
||||
mod cjk_numbers;
|
||||
pub mod cluster;
|
||||
@@ -26,6 +27,7 @@ pub mod fields;
|
||||
pub mod fill;
|
||||
pub mod freeform;
|
||||
pub mod graphic;
|
||||
pub mod ime_text;
|
||||
pub mod legacy_form;
|
||||
pub mod line_dash;
|
||||
pub mod link;
|
||||
@@ -639,6 +641,10 @@ pub struct Settings {
|
||||
/// after plus the second's space before, not the larger of the two.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub sum_paragraph_spacing: bool,
|
||||
/// `w:doNotExpandShiftReturn`: a justified line that ends in a manual line break keeps its
|
||||
/// natural spacing; by default Word stretches it to the margin like a wrapped line.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub do_not_expand_shift_return: bool,
|
||||
/// Keep contextual paragraph spacing inside legacy table cells (`allowSpaceOfSameStyleInTable`).
|
||||
/// Word ignores the layout override in compatibility mode 15, but the flag still round-trips.
|
||||
pub allow_space_of_same_style_in_table: bool,
|
||||
@@ -805,6 +811,7 @@ impl Default for Settings {
|
||||
compat_mode: COMPAT_MODE_CURRENT,
|
||||
roman_fonts: Vec::new(),
|
||||
sum_paragraph_spacing: false,
|
||||
do_not_expand_shift_return: false,
|
||||
allow_space_of_same_style_in_table: false,
|
||||
grid_h: DEFAULT_GRID,
|
||||
grid_v: DEFAULT_GRID,
|
||||
@@ -852,6 +859,50 @@ pub struct Document {
|
||||
/// The document's own building blocks (its glossary document), in file order.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub glossary: Vec<BuildingBlock>,
|
||||
/// Lookup cache for [`Document::add_media`]; not part of the document (never saved, ignored
|
||||
/// by `==`).
|
||||
#[serde(skip)]
|
||||
media_index: MediaIndex,
|
||||
}
|
||||
|
||||
/// [`Document::media`] by content, so adding media is linear in the bytes added rather than a
|
||||
/// scan of every earlier image (CWE-407). Rebuilt whenever `media` changed size behind its back;
|
||||
/// a hit is always checked against `media`, so a stale entry can at worst miss a duplicate.
|
||||
#[derive(Clone, Default)]
|
||||
struct MediaIndex {
|
||||
by_bytes: std::collections::HashMap<Arc<Vec<u8>>, String>,
|
||||
/// `media.len()` when the index last matched it.
|
||||
len: usize,
|
||||
/// The number after the last generated `image{n}` key, so taken names are probed once.
|
||||
next: usize,
|
||||
}
|
||||
|
||||
impl MediaIndex {
|
||||
fn rebuild(&mut self, media: &BTreeMap<String, Arc<Vec<u8>>>) {
|
||||
self.by_bytes.clear();
|
||||
// The first key (in key order) of equal bytes wins, as a scan would find it.
|
||||
for (k, v) in media.iter().rev() {
|
||||
self.by_bytes.insert(v.clone(), k.clone());
|
||||
}
|
||||
self.len = media.len();
|
||||
self.next = 0;
|
||||
}
|
||||
|
||||
fn find(&self, media: &BTreeMap<String, Arc<Vec<u8>>>, bytes: &Vec<u8>) -> Option<String> {
|
||||
self.by_bytes.get(bytes).filter(|k| media.get(*k).is_some_and(|v| **v == *bytes)).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for MediaIndex {
|
||||
fn eq(&self, _: &Self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for MediaIndex {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("MediaIndex")
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Document {
|
||||
@@ -880,6 +931,7 @@ impl Document {
|
||||
media: BTreeMap::new(),
|
||||
passthrough: BTreeMap::new(),
|
||||
glossary: Vec::new(),
|
||||
media_index: MediaIndex::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -955,6 +1007,7 @@ impl Document {
|
||||
media,
|
||||
passthrough,
|
||||
glossary,
|
||||
media_index: _,
|
||||
} = self;
|
||||
let Document {
|
||||
body: other_body,
|
||||
@@ -973,6 +1026,7 @@ impl Document {
|
||||
media: other_media,
|
||||
passthrough: other_passthrough,
|
||||
glossary: other_glossary,
|
||||
media_index: _,
|
||||
} = other;
|
||||
same_blocks(body, other_body)
|
||||
&& same_parts(parts, other_parts)
|
||||
@@ -1095,8 +1149,8 @@ impl Document {
|
||||
for story in stories {
|
||||
for path in self.para_paths(story) {
|
||||
let Some(p) = self.para(story, &path) else { continue };
|
||||
for off in p.object_offsets() {
|
||||
if p.object_at(off).is_some_and(|o| o.text_boxes().contains(&part)) {
|
||||
for (off, o) in p.objects_with_offsets() {
|
||||
if o.text_boxes().contains(&part) {
|
||||
return Some(Pos { story, path, off: off + para::OBJ.len_utf8() });
|
||||
}
|
||||
}
|
||||
@@ -1556,17 +1610,32 @@ impl Document {
|
||||
|
||||
/// Add a media item; returns its key (deduplicated by content).
|
||||
pub fn add_media(&mut self, bytes: Vec<u8>, ext: &str) -> String {
|
||||
if let Some((k, _)) = self.media.iter().find(|(_, v)| v.as_slice() == bytes.as_slice()) {
|
||||
return k.clone();
|
||||
let ix = &mut self.media_index;
|
||||
if ix.len != self.media.len() {
|
||||
ix.rebuild(&self.media);
|
||||
}
|
||||
let n = self.media.len() + 1;
|
||||
let mut key = format!("image{n}.{ext}");
|
||||
let mut i = n;
|
||||
if let Some(k) = ix.find(&self.media, &bytes) {
|
||||
return k;
|
||||
}
|
||||
if ix.by_bytes.contains_key(&bytes) {
|
||||
// Stale (`media` was edited directly without changing its size): look again.
|
||||
ix.rebuild(&self.media);
|
||||
if let Some(k) = ix.find(&self.media, &bytes) {
|
||||
return k;
|
||||
}
|
||||
}
|
||||
let n = self.media.len().saturating_add(1);
|
||||
let mut i = n.max(ix.next);
|
||||
let mut key = format!("image{i}.{ext}");
|
||||
while self.media.contains_key(&key) {
|
||||
i += 1;
|
||||
i = i.saturating_add(1);
|
||||
key = format!("image{i}.{ext}");
|
||||
}
|
||||
self.media.insert(key.clone(), Arc::new(bytes));
|
||||
let bytes = Arc::new(bytes);
|
||||
self.media.insert(key.clone(), bytes.clone());
|
||||
ix.by_bytes.entry(bytes).or_insert_with(|| key.clone());
|
||||
ix.len = self.media.len();
|
||||
ix.next = i.saturating_add(1);
|
||||
key
|
||||
}
|
||||
|
||||
@@ -1601,22 +1670,22 @@ impl Document {
|
||||
let mut out: Vec<PermRange> = Vec::new();
|
||||
let stories = std::iter::once(StoryRef::Body).chain(self.parts.keys().map(|k| StoryRef::Part(*k)));
|
||||
for story in stories {
|
||||
let mut open: Vec<usize> = Vec::new();
|
||||
// Open ranges by id, latest last (an end closes the latest open range of its id).
|
||||
let mut open: BTreeMap<u32, Vec<usize>> = BTreeMap::new();
|
||||
for path in self.para_paths(story) {
|
||||
let Some(p) = self.para(story, &path) else { continue };
|
||||
for off in p.object_offsets() {
|
||||
match p.object_at(off) {
|
||||
Some(InlineObject::PermStart { id, group, editor }) => {
|
||||
for (off, o) in p.objects_with_offsets() {
|
||||
match o {
|
||||
InlineObject::PermStart { id, group, editor } => {
|
||||
let start = Pos { story, path: path.clone(), off: off + para::OBJ.len_utf8() };
|
||||
open.push(out.len());
|
||||
open.entry(*id).or_default().push(out.len());
|
||||
out.push(PermRange { id: *id, group: group.clone(), editor: editor.clone(), start: start.clone(), end: start });
|
||||
}
|
||||
Some(InlineObject::PermEnd { id }) => {
|
||||
if let Some(k) = open.iter().rposition(|i| out.get(*i).is_some_and(|r| r.id == *id)) {
|
||||
let i = open.remove(k);
|
||||
if let Some(r) = out.get_mut(i) {
|
||||
r.end = Pos { story, path: path.clone(), off };
|
||||
}
|
||||
InlineObject::PermEnd { id } => {
|
||||
if let Some(i) = open.get_mut(id).and_then(Vec::pop)
|
||||
&& let Some(r) = out.get_mut(i)
|
||||
{
|
||||
r.end = Pos { story, path: path.clone(), off };
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -1625,7 +1694,7 @@ impl Document {
|
||||
}
|
||||
// A range left open runs to the end of its story.
|
||||
let end = self.end_of(story);
|
||||
for i in open {
|
||||
for i in open.into_values().flatten() {
|
||||
if let Some(r) = out.get_mut(i) {
|
||||
r.end = end.clone();
|
||||
}
|
||||
@@ -1639,8 +1708,8 @@ impl Document {
|
||||
let mut out = Vec::new();
|
||||
for path in self.para_paths(StoryRef::Body) {
|
||||
if let Some(p) = self.para(StoryRef::Body, &path) {
|
||||
for off in p.object_offsets() {
|
||||
if let Some(InlineObject::BookmarkStart { name }) = p.object_at(off) {
|
||||
for (off, o) in p.objects_with_offsets() {
|
||||
if let InlineObject::BookmarkStart { name } = o {
|
||||
out.push((name.clone(), Pos { story: StoryRef::Body, path: path.clone(), off }));
|
||||
}
|
||||
}
|
||||
@@ -1822,5 +1891,7 @@ fn story_word_count_of(bl: &Blocks, numbering: &Numbering, styles: &styles::Styl
|
||||
total
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test_log;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -41,6 +41,24 @@ impl Math {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.nodes.is_empty()
|
||||
}
|
||||
|
||||
/// Rough heap bytes a clone copies: every node, its argument lists and its run text.
|
||||
/// Iterative, so it costs at most the size of the structure.
|
||||
pub(crate) fn heap_bytes(&self) -> usize {
|
||||
let mut n = self.omml.len();
|
||||
let mut todo: Vec<&MNode> = self.nodes.iter().collect();
|
||||
while let Some(node) = todo.pop() {
|
||||
n = n.saturating_add(std::mem::size_of::<MNode>());
|
||||
if let MNode::Run(r) = node {
|
||||
n = n.saturating_add(r.text.len()).saturating_add(r.font.as_ref().map_or(0, String::len));
|
||||
}
|
||||
each_arg(node, &mut |a| {
|
||||
n = n.saturating_add(std::mem::size_of::<Arg>());
|
||||
todo.extend(a);
|
||||
});
|
||||
}
|
||||
n
|
||||
}
|
||||
}
|
||||
|
||||
/// Display equation justification (`m:oMathParaPr/m:jc`).
|
||||
@@ -1095,4 +1113,20 @@ mod tests {
|
||||
let deep = "√".repeat(3000);
|
||||
let _ = parse_linear(&deep);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_run_of_letters_parses_in_linear_time() {
|
||||
// Each letter that doesn't start a function name used to rescan (and copy) the rest of
|
||||
// the run, so a 20,000-letter equation did 200 million steps. (No wall-clock assert: a
|
||||
// loaded runner would fail it; a regression shows as a slow test.)
|
||||
for _ in 0..5 {
|
||||
let e = parse_linear(&"x".repeat(30_000));
|
||||
assert!(matches!(e.as_slice(), [MNode::Run(r)] if r.text.len() == 20_000), "{:?}", e.len());
|
||||
}
|
||||
// Function names are still found at the start of a run, and only as whole runs.
|
||||
assert!(matches!(parse_linear("sin x").as_slice(), [MNode::Func { .. }]));
|
||||
assert!(matches!(parse_linear("arcsin x").as_slice(), [MNode::Func { .. }]));
|
||||
assert!(matches!(parse_linear("sinx").as_slice(), [MNode::Run(_)]));
|
||||
assert!(matches!(parse_linear("arcsinhx").as_slice(), [MNode::Run(_)]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,6 +324,21 @@ impl Lp {
|
||||
let den = self.arg();
|
||||
one(MNode::Frac { kind: FracKind::Bar, num, den })
|
||||
}
|
||||
"genfrac" => {
|
||||
let beg = delim_arg(&self.arg());
|
||||
let end = delim_arg(&self.arg());
|
||||
let thickness = self.arg();
|
||||
let _style = self.arg();
|
||||
let num = self.arg();
|
||||
let den = self.arg();
|
||||
let kind = if is_zero_length(&thickness) { FracKind::NoBar } else { FracKind::Bar };
|
||||
let frac = MNode::Frac { kind, num, den };
|
||||
if beg.is_none() && end.is_none() {
|
||||
one(frac)
|
||||
} else {
|
||||
one(MNode::Delim { beg, end, sep: None, grow: true, shp_match: false, elems: vec![vec![frac]] })
|
||||
}
|
||||
}
|
||||
"binom" | "dbinom" | "tbinom" => {
|
||||
let n = self.arg();
|
||||
let k = self.arg();
|
||||
@@ -801,6 +816,31 @@ fn text_chars(text: &str, s: &mut String) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The delimiter of a `\genfrac` argument: one character, or none for `{}` and anything else.
|
||||
fn delim_arg(a: &[MNode]) -> Option<char> {
|
||||
let [MNode::Run(r)] = a else { return None };
|
||||
let mut cs = r.text.chars();
|
||||
match (cs.next(), cs.next()) {
|
||||
(Some(c), None) if c != '.' => Some(c),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a `\genfrac` thickness is a zero length such as `0pt`, `0.0pt` or `0 em`. An empty
|
||||
/// thickness means the default rule and anything that is not a plain number and unit is not zero.
|
||||
fn is_zero_length(a: &[MNode]) -> bool {
|
||||
let mut text = String::new();
|
||||
for n in a {
|
||||
let MNode::Run(r) = n else { return false };
|
||||
text.extend(r.text.chars().filter(|c| !c.is_whitespace() && !matches!(c, '\u{2009}' | '\u{205F}' | '\u{2005}')));
|
||||
}
|
||||
let number = text.trim_end_matches(|c: char| c.is_ascii_alphabetic());
|
||||
let unit = &text[number.len()..];
|
||||
!number.is_empty()
|
||||
&& matches!(unit, "pt" | "em" | "ex" | "mm" | "cm" | "in" | "bp" | "pc" | "sp" | "mu")
|
||||
&& number.parse::<f64>().is_ok_and(|v| v == 0.0)
|
||||
}
|
||||
|
||||
fn delim_tex(c: Option<char>) -> String {
|
||||
match c {
|
||||
None => ".".into(),
|
||||
@@ -1076,6 +1116,34 @@ mod tests {
|
||||
let _ = (LimLoc::UndOvr, MathJc::Center);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latex_binom_round_trip() {
|
||||
let a = parse_latex(r"\binom{n}{k}");
|
||||
let l = to_latex(&a);
|
||||
assert_eq!(parse_latex(&l), a, "exported as {l}");
|
||||
let nobar = |n: &str, k: &str| MNode::Frac { kind: FracKind::NoBar, num: parse_latex(n), den: parse_latex(k) };
|
||||
let b = parse_latex(r"\genfrac{}{}{0pt}{}{n}{k}");
|
||||
assert_eq!(b, vec![nobar("n", "k")]);
|
||||
assert_eq!(parse_latex(&to_latex(&b)), b);
|
||||
// Other spellings of a zero thickness.
|
||||
for z in ["0 pt", "0.0pt", r"0\,pt", "0em"] {
|
||||
let c = parse_latex(&format!(r"\genfrac{{}}{{}}{{{z}}}{{}}{{n}}{{k}}"));
|
||||
assert_eq!(c, vec![nobar("n", "k")], "thickness {z}");
|
||||
}
|
||||
// A non-zero or default thickness keeps the bar.
|
||||
for t in ["1pt", "0.4pt", ""] {
|
||||
let c = parse_latex(&format!(r"\genfrac{{}}{{}}{{{t}}}{{}}{{n}}{{k}}"));
|
||||
let bar = MNode::Frac { kind: FracKind::Bar, num: parse_latex("n"), den: parse_latex("k") };
|
||||
assert_eq!(c, vec![bar], "thickness {t:?}");
|
||||
}
|
||||
// Delimiters are kept.
|
||||
let d = parse_latex(r"\genfrac(){0pt}{}{n}{k}");
|
||||
assert_eq!(d, a);
|
||||
let e = parse_latex(r"\genfrac[]{0pt}{}{n}{k}");
|
||||
assert!(matches!(&e[..], [MNode::Delim { beg: Some('['), end: Some(']'), .. }]), "{e:#?}");
|
||||
assert_eq!(parse_latex(&to_latex(&e)), e);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latex_round_trip() {
|
||||
for lin in ["x=(-b±√(b^2-4ac))/2a", "∑_(i=1)^n▒i^2", "√(3&x)", "a⃗+x̂", "■(a&b@c&d)", "sinx+cosy", "e^(-x^2)"] {
|
||||
|
||||
@@ -18,6 +18,18 @@ pub const FUNCTION_NAMES: &[&str] = &[
|
||||
"tanh",
|
||||
];
|
||||
|
||||
/// Length of the longest of [`FUNCTION_NAMES`].
|
||||
const MAX_NAME: usize = {
|
||||
let (mut max, mut i) = (0, 0);
|
||||
while i < FUNCTION_NAMES.len() {
|
||||
if FUNCTION_NAMES[i].len() > max {
|
||||
max = FUNCTION_NAMES[i].len();
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
max
|
||||
};
|
||||
|
||||
/// Function names whose subscripts go underneath (`lim_(n→∞)`).
|
||||
const LIMIT_NAMES: &[&str] = &["lim", "liminf", "limsup", "max", "min", "sup", "inf", "det", "gcd", "Pr"];
|
||||
|
||||
@@ -410,10 +422,12 @@ impl P {
|
||||
fn word(&mut self, stops: &[char]) -> Arg {
|
||||
let start = self.i;
|
||||
let mut end = start;
|
||||
while self.c.get(end).is_some_and(char::is_ascii_alphabetic) {
|
||||
// Look no further than the longest name (and one past it, to see the run is longer): a
|
||||
// scan to the end of every run made a long run of letters quadratic (CWE-407).
|
||||
while end - start <= MAX_NAME && self.c.get(end).is_some_and(char::is_ascii_alphabetic) {
|
||||
end += 1;
|
||||
}
|
||||
let word: String = self.c.get(start..end).unwrap_or(&[]).iter().collect();
|
||||
let word: String = if end - start > MAX_NAME { String::new() } else { self.c.get(start..end).unwrap_or(&[]).iter().collect() };
|
||||
if !FUNCTION_NAMES.contains(&word.as_str()) {
|
||||
self.i = start + 1;
|
||||
return vec![text(self.c.get(start).copied().unwrap_or(' '))];
|
||||
@@ -556,7 +570,7 @@ impl P {
|
||||
}
|
||||
out.pop()
|
||||
};
|
||||
if !matches!(c, '^' | '_') && !(matches!(c, '┬' | '┴') && self.no_limits == 0) && !is_combining(c) {
|
||||
if !(matches!(c, '^' | '_') || matches!(c, '┬' | '┴') && self.no_limits == 0 || is_combining(c)) {
|
||||
break;
|
||||
}
|
||||
// The base is already deep: the operator is text (see `too_deep`).
|
||||
|
||||
@@ -357,7 +357,8 @@ impl Counters {
|
||||
return Some((def.text.clone(), def));
|
||||
}
|
||||
let mut label = String::new();
|
||||
let mut chars = def.text.chars().peekable();
|
||||
// Word's level text is short; .docx and the lists command already cap it at 64.
|
||||
let mut chars = def.text.chars().take(MAX_LEVEL_TEXT).peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '%'
|
||||
&& let Some(d) = chars.peek().and_then(|d| d.to_digit(10))
|
||||
@@ -375,6 +376,9 @@ impl Counters {
|
||||
f => f,
|
||||
};
|
||||
label.push_str(&fmt.format(val));
|
||||
if label.len() > MAX_LABEL_BYTES {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
label.push(c);
|
||||
}
|
||||
@@ -383,10 +387,29 @@ impl Counters {
|
||||
}
|
||||
}
|
||||
|
||||
/// Longest level text (in characters) expanded into a list label.
|
||||
const MAX_LEVEL_TEXT: usize = 64;
|
||||
/// Labels stop growing once they pass this many bytes.
|
||||
const MAX_LABEL_BYTES: usize = 256;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_huge_level_text_gives_a_bounded_label() {
|
||||
let mut n = Numbering::default();
|
||||
let id = n.add_list(ListKind::Numbered);
|
||||
let huge = Level { format: NumFormat::UpperRoman, text: "%1".repeat(30_000), start: u32::MAX, ..Level::default() };
|
||||
if let Some(num) = n.nums.iter_mut().find(|x| x.id == id) {
|
||||
num.level_overrides = vec![(0, huge)];
|
||||
}
|
||||
let mut c = Counters::default();
|
||||
let label = c.next_label(&n, id, 0).unwrap().0;
|
||||
assert!(label.len() <= 512, "label is {} bytes", label.len());
|
||||
assert!(label.starts_with("MMMM"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_list_s_own_level_definitions_win() {
|
||||
// A specification's article numbers: level 1 counts parts with no label of its own, level 2
|
||||
|
||||
@@ -1089,6 +1089,12 @@ impl Paragraph {
|
||||
pub fn object_offsets(&self) -> Vec<usize> {
|
||||
self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).collect()
|
||||
}
|
||||
/// (byte offset, object) for every object, in text order, in one pass. Prefer it to
|
||||
/// [`Paragraph::object_offsets`] + [`Paragraph::object_at`], which counts the objects before
|
||||
/// each offset again, so a loop over a paragraph's objects is quadratic (#3614).
|
||||
pub fn objects_with_offsets(&self) -> impl Iterator<Item = (usize, &InlineObject)> + '_ {
|
||||
self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).zip(self.objects.iter())
|
||||
}
|
||||
|
||||
/// Insert plain text (U+FFFC is dropped) at `off` with `props`. Returns the inserted length.
|
||||
pub fn insert_text(&mut self, off: usize, s: &str, props: &CharProps) -> Result<usize> {
|
||||
@@ -1559,6 +1565,11 @@ mod tests {
|
||||
assert!(matches!(p.object_at(0), Some(InlineObject::BookmarkStart { .. })));
|
||||
let off = p.object_offsets()[1];
|
||||
assert!(matches!(p.object_at(off), Some(InlineObject::Field { .. })));
|
||||
// The one-pass walk agrees with offsets + object_at.
|
||||
let one_pass: Vec<(usize, &InlineObject)> = p.objects_with_offsets().collect();
|
||||
let each: Vec<(usize, &InlineObject)> = p.object_offsets().into_iter().filter_map(|o| Some((o, p.object_at(o)?))).collect();
|
||||
assert_eq!(one_pass, each);
|
||||
assert_eq!(one_pass.len(), 2);
|
||||
assert_eq!(p.plain_text(), "a1b");
|
||||
p.delete(0, 3).unwrap();
|
||||
assert_eq!(p.objects.len(), 1);
|
||||
|
||||
@@ -255,7 +255,7 @@ impl NumFormat {
|
||||
pub fn from_ooxml(s: &str) -> NumFormat {
|
||||
NumFormat::ALL.into_iter().find(|f| f.ooxml() == s).unwrap_or(NumFormat::Decimal)
|
||||
}
|
||||
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Arabic, Hindi, Thai,
|
||||
/// Format `n` (1-based). Formats WordCraft doesn't draw yet (Hebrew, Hindi, Thai,
|
||||
/// Vietnamese, text amounts) show decimal, as do values past a sequence's end
|
||||
/// (the 21st item of ①…⑳).
|
||||
pub fn format(self, n: u32) -> String {
|
||||
@@ -268,6 +268,8 @@ impl NumFormat {
|
||||
NumFormat::LowerLetter => letters(n),
|
||||
NumFormat::RussianUpper => russian_letters(n).to_uppercase(),
|
||||
NumFormat::RussianLower => russian_letters(n),
|
||||
NumFormat::ArabicAlpha => repeated_letter(&ARABIC_ALPHA, n),
|
||||
NumFormat::ArabicAbjad => repeated_letter(&ARABIC_ABJAD, n),
|
||||
NumFormat::Ordinal => format!("{n}{}", ordinal_suffix(n)),
|
||||
NumFormat::CardinalText => cardinal_text(n),
|
||||
NumFormat::OrdinalText => ordinal_text(n),
|
||||
@@ -308,8 +310,6 @@ impl NumFormat {
|
||||
| NumFormat::VietnameseCounting
|
||||
| NumFormat::Hebrew1
|
||||
| NumFormat::Hebrew2
|
||||
| NumFormat::ArabicAlpha
|
||||
| NumFormat::ArabicAbjad
|
||||
| NumFormat::HindiVowels
|
||||
| NumFormat::HindiConsonants
|
||||
| NumFormat::HindiNumbers
|
||||
@@ -399,6 +399,27 @@ pub fn russian_letters(n: u32) -> String {
|
||||
std::iter::repeat_n(c, k as usize).collect()
|
||||
}
|
||||
|
||||
/// `arabicAlpha`: the 28 letters in alphabetical (hijā'ī) order.
|
||||
const ARABIC_ALPHA: [char; 28] =
|
||||
['أ', 'ب', 'ت', 'ث', 'ج', 'ح', 'خ', 'د', 'ذ', 'ر', 'ز', 'س', 'ش', 'ص', 'ض', 'ط', 'ظ', 'ع', 'غ', 'ف', 'ق', 'ك', 'ل', 'م', 'ن', 'ه', 'و', 'ي'];
|
||||
|
||||
/// `arabicAbjad`: the 28 letters in abjad order (أبجد هوز حطي كلمن سعفص قرشت ثخذ ضظغ).
|
||||
const ARABIC_ABJAD: [char; 28] =
|
||||
['أ', 'ب', 'ج', 'د', 'ه', 'و', 'ز', 'ح', 'ط', 'ي', 'ك', 'ل', 'م', 'ن', 'س', 'ع', 'ف', 'ص', 'ق', 'ر', 'ش', 'ت', 'ث', 'خ', 'ذ', 'ض', 'ظ', 'غ'];
|
||||
|
||||
/// One letter of `alphabet`, repeated once per pass through it (as [`letters`]: a..z, aa..zz).
|
||||
fn repeated_letter(alphabet: &[char], n: u32) -> String {
|
||||
let Some(&first) = alphabet.first() else { return n.to_string() };
|
||||
if n == 0 {
|
||||
return String::new();
|
||||
}
|
||||
let len = alphabet.len() as u32;
|
||||
let n = n.min(len * 30);
|
||||
let k = (n - 1) / len + 1;
|
||||
let c = alphabet.get(((n - 1) % len) as usize).copied().unwrap_or(first);
|
||||
std::iter::repeat_n(c, k as usize).collect()
|
||||
}
|
||||
|
||||
const ONES: [&str; 20] = [
|
||||
"zero",
|
||||
"one",
|
||||
@@ -920,6 +941,19 @@ mod tests {
|
||||
assert_eq!(NumFormat::RussianUpper.format(36), "ЖЖ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_letters_count_in_both_orders() {
|
||||
let alpha: Vec<String> = (1..=5).map(|n| NumFormat::ArabicAlpha.format(n)).collect();
|
||||
assert_eq!(alpha.join(" "), "أ ب ت ث ج");
|
||||
let abjad: Vec<String> = (1..=5).map(|n| NumFormat::ArabicAbjad.format(n)).collect();
|
||||
assert_eq!(abjad.join(" "), "أ ب ج د ه");
|
||||
assert_eq!(NumFormat::ArabicAlpha.format(28), "ي");
|
||||
assert_eq!(NumFormat::ArabicAbjad.format(28), "غ");
|
||||
// Past the 28th letter they double, as Latin and Russian letters do.
|
||||
assert_eq!(NumFormat::ArabicAlpha.format(29), "أأ");
|
||||
assert_eq!(NumFormat::ArabicAbjad.format(30), "بب");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn landscape_swaps() {
|
||||
let mut s = SectionProps { margin_left: 50.0, ..Default::default() };
|
||||
|
||||
@@ -48,6 +48,12 @@ impl Row {
|
||||
pub fn first_grid(&self) -> usize {
|
||||
(self.props.grid_before as usize).min(MAX_COLS)
|
||||
}
|
||||
|
||||
/// Grid columns `w:gridAfter` leaves empty after the row's last cell, bounded as the DOCX
|
||||
/// reader bounds them (a native document can say anything).
|
||||
pub fn grid_after(&self) -> usize {
|
||||
(self.props.grid_after as usize).min(MAX_COLS)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
@@ -97,7 +103,8 @@ impl Table {
|
||||
}
|
||||
|
||||
pub fn cols(&self) -> usize {
|
||||
let row_cols = |r: &Row| r.first_grid() + r.cells.iter().map(Cell::span).sum::<usize>() + r.props.grid_after as usize;
|
||||
let row_cols =
|
||||
|r: &Row| r.first_grid().saturating_add(r.cells.iter().map(Cell::span).fold(0, usize::saturating_add)).saturating_add(r.grid_after());
|
||||
self.grid.len().max(self.rows.iter().map(row_cols).max().unwrap_or(0))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Test helper: the warnings this crate logs, captured per test thread, so a test can check that
|
||||
//! a limit which leaves something out says so.
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
struct Capture;
|
||||
|
||||
thread_local! {
|
||||
static LINES: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
impl log::Log for Capture {
|
||||
fn enabled(&self, m: &log::Metadata) -> bool {
|
||||
m.level() <= log::Level::Warn
|
||||
}
|
||||
|
||||
fn log(&self, r: &log::Record) {
|
||||
if self.enabled(r.metadata()) {
|
||||
LINES.with(|l| l.borrow_mut().push(r.args().to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
static CAPTURE: Capture = Capture;
|
||||
|
||||
/// `f`'s result and the warnings logged on this thread while it ran.
|
||||
pub(crate) fn warnings<T>(f: impl FnOnce() -> T) -> (T, Vec<String>) {
|
||||
// Only this logger is ever installed in a test binary; a second call keeps it.
|
||||
let _ = log::set_logger(&CAPTURE);
|
||||
log::set_max_level(log::LevelFilter::Warn);
|
||||
LINES.with(|l| l.borrow_mut().clear());
|
||||
let out = f();
|
||||
(out, LINES.with(|l| std::mem::take(&mut *l.borrow_mut())))
|
||||
}
|
||||
@@ -90,6 +90,45 @@ fn parts_and_media() {
|
||||
assert_ne!(k1, k3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_distinct_media_add_in_linear_time() {
|
||||
// An import with tens of thousands of small distinct images (same length, differing only at
|
||||
// the end) used to compare each against every earlier one.
|
||||
let mut d = Document::new();
|
||||
let t = std::time::Instant::now();
|
||||
let n = 40_000u32;
|
||||
for i in 0..n {
|
||||
let mut bytes = vec![7u8; 256];
|
||||
bytes.extend(i.to_le_bytes());
|
||||
d.add_media(bytes, "png");
|
||||
}
|
||||
assert_eq!(d.media.len(), n as usize);
|
||||
// Duplicates still find their first copy, including media inserted directly.
|
||||
let mut first = vec![7u8; 256];
|
||||
first.extend(0u32.to_le_bytes());
|
||||
assert_eq!(d.add_media(first, "png"), "image1.png");
|
||||
d.media.insert("direct.png".into(), Arc::new(vec![9, 9]));
|
||||
assert_eq!(d.add_media(vec![9, 9], "png"), "direct.png");
|
||||
// About half a second on a loaded machine; the old scan took over two minutes.
|
||||
assert!(t.elapsed().as_secs_f32() < 10.0, "{:?}", t.elapsed());
|
||||
// The lookup cache isn't part of the document.
|
||||
let mut e = d.clone();
|
||||
e.media_index = MediaIndex::default();
|
||||
assert_eq!(d, e);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_keys_skip_taken_names_once() {
|
||||
let mut d = Document::new();
|
||||
// A file whose media already use the names add_media would pick next.
|
||||
for i in 2..2002u32 {
|
||||
d.media.insert(format!("image{i}.png"), Arc::new(i.to_le_bytes().to_vec()));
|
||||
}
|
||||
assert_eq!(d.add_media(vec![0], "png"), "image2002.png");
|
||||
assert_eq!(d.add_media(vec![1], "png"), "image2003.png");
|
||||
assert_eq!(d.add_media(vec![2], "png"), "image2004.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_ids_skip_taken_ones_instead_of_overflowing() {
|
||||
assert_eq!(free_id(None, std::iter::empty(), 1), Some(1));
|
||||
@@ -523,3 +562,92 @@ fn custom_props_set_get_remove() {
|
||||
assert!(!d.remove_custom_prop("other"));
|
||||
assert_eq!(d.custom_props.len(), 1);
|
||||
}
|
||||
|
||||
/// A paragraph that is nothing but `objs` (built directly: inserting one at a time is slow too).
|
||||
fn para_of_objects(objs: Vec<InlineObject>) -> Paragraph {
|
||||
let mut p = Paragraph::new();
|
||||
p.text = para::OBJ.to_string().repeat(objs.len());
|
||||
p.runs = vec![Run { len: p.text.len(), props: CharProps::default() }];
|
||||
p.objects = objs;
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_walks_are_linear_in_the_paragraph() {
|
||||
// #3614: loops of object_offsets + object_at recounted the objects before each one, so a
|
||||
// paragraph of 40,000 markers took seconds per walk.
|
||||
let n = 40_000u32;
|
||||
let w = para::OBJ.len_utf8();
|
||||
let mut objs: Vec<InlineObject> = Vec::new();
|
||||
for i in 0..n {
|
||||
objs.push(InlineObject::BookmarkStart { name: format!("b{i}") });
|
||||
objs.push(fs("ADDIN x"));
|
||||
objs.push(InlineObject::PermStart { id: i, group: "everyone".into(), editor: String::new() });
|
||||
}
|
||||
// Ranges close in the order they opened (the worst case for a backwards search).
|
||||
for i in 0..n {
|
||||
objs.push(InlineObject::FieldEnd);
|
||||
objs.push(InlineObject::PermEnd { id: i });
|
||||
}
|
||||
let mut d = Document::new();
|
||||
d.body = vec![Arc::new(Block::Para(para_of_objects(objs)))];
|
||||
let t = std::time::Instant::now();
|
||||
assert_eq!(d.bookmarks().len(), n as usize);
|
||||
assert_eq!(d.field_ranges(StoryRef::Body).len(), n as usize);
|
||||
let perms = d.perm_ranges();
|
||||
assert_eq!(perms.len(), n as usize);
|
||||
assert!(perms.iter().all(|r| r.end.off > r.start.off), "every range closed by its own end");
|
||||
assert_eq!(d.text_box_anchor(7), None);
|
||||
// A caret before a long row of field ends moves past all of them.
|
||||
let first_end = 3 * n as usize * w;
|
||||
let p = d.past_field_ends(&Pos::body(0, first_end));
|
||||
assert_eq!(p.off, first_end + w);
|
||||
// Under 0.3 s on a loaded machine; the old walks took minutes (360 s for the first test).
|
||||
assert!(t.elapsed().as_secs_f32() < 10.0, "{:?}", t.elapsed());
|
||||
|
||||
let ends: Vec<InlineObject> = (0..n).map(|_| InlineObject::FieldEnd).collect();
|
||||
d.body = vec![Arc::new(Block::Para(para_of_objects(ends)))];
|
||||
let t = std::time::Instant::now();
|
||||
assert_eq!(d.past_field_ends(&Pos::body(0, 0)).off, n as usize * w);
|
||||
// Typing at the end of a row of block-level control starts.
|
||||
let block = ContentControl { block: true, ..Default::default() };
|
||||
let starts: Vec<InlineObject> = (0..n).map(|_| InlineObject::ControlStart { control: Box::new(block.clone()) }).collect();
|
||||
d.body = vec![Arc::new(Block::Para(para_of_objects(starts)))];
|
||||
let end = Pos::body(0, n as usize * w);
|
||||
assert_eq!(d.typing_pos(&end), end);
|
||||
assert!(t.elapsed().as_secs_f32() < 10.0, "{:?}", t.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_across_many_lone_control_markers_is_not_quadratic() {
|
||||
// A millisecond now; the old delete took 18 s at 1,500 markers and 114 s at 3,000.
|
||||
let n = 3_000;
|
||||
let starts: Vec<InlineObject> = (0..n).map(|_| InlineObject::ControlStart { control: Box::default() }).collect();
|
||||
let mut d = Document::from_text("before\n\nafter");
|
||||
d.body[1] = Arc::new(Block::Para(para_of_objects(starts)));
|
||||
let t = std::time::Instant::now();
|
||||
let at = d.delete_range(&Pos::body(0, 3), &Pos::body(2, 2)).unwrap();
|
||||
assert_eq!(at, Pos::body(0, 3));
|
||||
assert_eq!(d.plain_text(StoryRef::Body), "befter");
|
||||
assert!(t.elapsed().as_secs_f32() < 10.0, "{:?}", t.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_markers_kept_are_sorted() {
|
||||
// `delete_range` groups the kept markers by paragraph and binary-searches each group, so
|
||||
// `kept_control_markers` must hand them over sorted and without repeats (review of #3874).
|
||||
let start = || InlineObject::ControlStart { control: Box::default() };
|
||||
let mut d = Document::from_text("before");
|
||||
// Controls open and close across paragraphs: 1 closes the one 0 opens, 3 the one 2 opens…
|
||||
for i in 0..12 {
|
||||
let objs = if i % 2 == 0 { vec![start(), start()] } else { vec![InlineObject::ControlEnd, InlineObject::ControlEnd] };
|
||||
d.body.push(Arc::new(Block::Para(para_of_objects(objs))));
|
||||
}
|
||||
d.body.push(Arc::new(Block::Para(Paragraph::with_text("after", CharProps::default()))));
|
||||
let w = para::OBJ.len_utf8();
|
||||
for (a, b) in [(Pos::body(2, w), Pos::body(11, w)), (Pos::body(1, w), Pos::body(12, w)), (Pos::body(3, w), Pos::body(8, 0))] {
|
||||
let kept = d.kept_control_markers(&a, &b);
|
||||
assert!(!kept.is_empty(), "{a:?}..{b:?}");
|
||||
assert!(kept.windows(2).all(|p| p[0] < p[1]), "sorted, no repeats: {kept:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,5 +20,6 @@ log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
wordcraft-doc = { workspace = true, features = ["test-support"] }
|
||||
proptest = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -295,8 +295,13 @@ impl Cipher {
|
||||
if hflags & H_EXTERNAL != 0 {
|
||||
return Err(unsupported("the document uses an external encryption provider"));
|
||||
}
|
||||
// [MS-OFFCRYPTO] defines AES only for the encrypted packages of Word 2007 and later
|
||||
// (which open through the .docx reader whatever their extension); a Word 97-2003 file
|
||||
// may only use XOR, RC4 or RC4 CryptoAPI, so there is no public scheme to follow here.
|
||||
if hflags & H_AES != 0 {
|
||||
return Err(unsupported("AES-encrypted .doc files are not supported"));
|
||||
return Err(unsupported(
|
||||
"this Word 97-2003 file claims AES encryption, which is only defined for the newer encrypted-package format; it can't be opened",
|
||||
));
|
||||
}
|
||||
if hflags & H_CRYPTO_API == 0 {
|
||||
return Err(unsupported("unknown encryption header"));
|
||||
|
||||
@@ -81,12 +81,18 @@ pub(crate) fn anchors(
|
||||
) -> Vec<(u32, InlineObject)> {
|
||||
let Some(pair) = pair else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for (cp, spa) in plc(table, pair, CB_SPA) {
|
||||
let i32_at = |o: usize| spa.get(o..o + 4).and_then(|b| b.try_into().ok()).map(i32::from_le_bytes).unwrap_or(0);
|
||||
let spid = i32_at(0) as u32;
|
||||
let [xl, yt, xr, yb] = [4, 8, 12, 16].map(i32_at);
|
||||
let flags = spa.get(20..22).map(|b| u16::from_le_bytes([b[0], b[1]])).unwrap_or(0);
|
||||
let Some(node) = shapes.get(&spid) else { continue };
|
||||
// Word anchors each shape once. A repeated spid would build another copy of the shape,
|
||||
// and of every member of a group, per anchor (#3710).
|
||||
if !seen.insert(spid) {
|
||||
continue;
|
||||
}
|
||||
let tw = |v: i64| (v as f32 / 20.0).clamp(-2.0 * MAX_PT, 2.0 * MAX_PT);
|
||||
let (mut x, mut y) = (tw(xl as i64), tw(yt as i64));
|
||||
let (mut w, mut h) = (tw(xr as i64 - xl as i64).clamp(0.0, MAX_PT), tw(yb as i64 - yt as i64).clamp(0.0, MAX_PT));
|
||||
|
||||
+191
-29
@@ -49,6 +49,91 @@ const MAX_PARTS: usize = 50_000;
|
||||
/// headers, notes, text boxes), whatever the file's size. The working budget is tied to the
|
||||
/// WordDocument stream (see [`read`]); this caps even a maximal stream.
|
||||
const MAX_TOTAL_CHARS: u64 = 64 << 20;
|
||||
/// Bytes of repeated work per document beyond the text itself: piece Prm grpprls (Prcs, up
|
||||
/// to 64 KB each) interpreted (#3692), hyperlink targets (#3708), picture alt text (#3709)
|
||||
/// and floating objects (#3710) copied into every run or anchor. Past it Prms are skipped,
|
||||
/// copied strings left empty and floating objects dropped, each kind logged once a document
|
||||
/// ([`Work::log`]).
|
||||
const MAX_WORK: u64 = 64 << 20;
|
||||
|
||||
/// What the [`MAX_WORK`] budget pays for, and leaves out once it is spent.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Cost {
|
||||
/// A piece's Prm grpprl: past the budget the piece keeps its CHPX formatting only.
|
||||
Formatting,
|
||||
/// A hyperlink target copied into a run of its result: past it the text is kept, unlinked.
|
||||
LinkTarget,
|
||||
/// A picture's alt text copied into an anchor: past it the picture has none.
|
||||
AltText,
|
||||
/// A floating object copied into its anchor: past it the object is left out.
|
||||
FloatingObject,
|
||||
}
|
||||
|
||||
impl Cost {
|
||||
const ALL: [Cost; 4] = [Cost::Formatting, Cost::LinkTarget, Cost::AltText, Cost::FloatingObject];
|
||||
|
||||
/// What is left out, for the log.
|
||||
fn left_out(self) -> &'static str {
|
||||
match self {
|
||||
Cost::Formatting => "some text keeps only part of its formatting",
|
||||
Cost::LinkTarget => "some hyperlinks' text is kept without its link",
|
||||
Cost::AltText => "some pictures are left without their alt text",
|
||||
Cost::FloatingObject => "some floating shapes and pictures are left out",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One document's [`MAX_WORK`] budget, and what it has left out (each kind logged once when
|
||||
/// the document has been read, [`Work::log`]).
|
||||
struct Work {
|
||||
left: std::cell::Cell<u64>,
|
||||
dropped: std::cell::Cell<[bool; 4]>,
|
||||
}
|
||||
|
||||
impl Work {
|
||||
fn new(budget: u64) -> Self {
|
||||
Work { left: std::cell::Cell::new(budget), dropped: std::cell::Cell::new([false; 4]) }
|
||||
}
|
||||
|
||||
/// Spends `cost` bytes on `what`: false (and `what` noted as left out) when they aren't left.
|
||||
fn charge(&self, cost: u64, what: Cost) -> bool {
|
||||
match self.left.get().checked_sub(cost) {
|
||||
Some(left) => {
|
||||
self.left.set(left);
|
||||
true
|
||||
}
|
||||
None => {
|
||||
let mut dropped = self.dropped.get();
|
||||
if let Some(d) = Cost::ALL.iter().position(|c| *c == what).and_then(|i| dropped.get_mut(i)) {
|
||||
*d = true;
|
||||
}
|
||||
self.dropped.set(dropped);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The kinds of work left out so far, in [`Cost::ALL`] order.
|
||||
fn dropped(&self) -> Vec<Cost> {
|
||||
Cost::ALL.iter().zip(self.dropped.get()).filter(|(_, d)| *d).map(|(c, _)| *c).collect()
|
||||
}
|
||||
|
||||
/// Logs one warning for each kind left out.
|
||||
fn log(&self) {
|
||||
let dropped = self.dropped();
|
||||
for c in &dropped {
|
||||
log::warn!(".doc: the document repeats more than WordCraft copies ({MAX_WORK} bytes): {}", c.left_out());
|
||||
}
|
||||
#[cfg(test)]
|
||||
LAST_DROPPED.with(|d| *d.borrow_mut() = dropped);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// What the last document read on this thread left out for the work budget ([`Work::log`]).
|
||||
static LAST_DROPPED: std::cell::RefCell<Vec<Cost>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
/// `sprmCPicLocation`: the fc of a picture in the Data stream.
|
||||
const C_PIC_LOCATION: u16 = 0x6A03;
|
||||
/// `sprmCSymbol`: the record font and character of a symbol character ([MS-DOC] §2.6.1); the
|
||||
@@ -88,10 +173,16 @@ pub enum DocbinError {
|
||||
/// Is this `.doc` password-protected (XOR obfuscation, RC4 or RC4 CryptoAPI)? False for
|
||||
/// anything that is not a readable Word 97-2003 file.
|
||||
pub fn is_encrypted(bytes: &[u8]) -> bool {
|
||||
let Ok(mut comp) = cfb::CompoundFile::open(std::io::Cursor::new(bytes)) else { return false };
|
||||
let Ok(mut comp) = open_container(bytes) else { return false };
|
||||
stream(&mut comp, "WordDocument").is_some_and(|w| fib::peek_encryption(&w).is_some())
|
||||
}
|
||||
|
||||
/// Open the compound file around a `.doc`, after [`wordcraft_doc::cfb_guard::check`].
|
||||
fn open_container(bytes: &[u8]) -> Result<cfb::CompoundFile<std::io::Cursor<&[u8]>>, DocbinError> {
|
||||
wordcraft_doc::cfb_guard::check(bytes).map_err(DocbinError::Container)?;
|
||||
cfb::CompoundFile::open(std::io::Cursor::new(bytes)).map_err(|e| DocbinError::Container(e.to_string()))
|
||||
}
|
||||
|
||||
/// Read a `.doc` (Word 97-2003 binary) file into a [`Document`]. A password-protected file
|
||||
/// gives [`DocbinError::PasswordRequired`]; see [`read_with_password`].
|
||||
pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
|
||||
@@ -102,7 +193,7 @@ pub fn read(bytes: &[u8]) -> Result<Document, DocbinError> {
|
||||
/// verifier ([`DocbinError::WrongPassword`] when it does not match) and then decrypted. The
|
||||
/// password is ignored for an unprotected file.
|
||||
pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Document, DocbinError> {
|
||||
let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(bytes)).map_err(|e| DocbinError::Container(e.to_string()))?;
|
||||
let mut comp = open_container(bytes)?;
|
||||
let mut word = stream(&mut comp, "WordDocument").ok_or_else(|| DocbinError::NotWord("no WordDocument stream".into()))?;
|
||||
if word.len() as u64 > MAX_STREAM {
|
||||
return Err(DocbinError::Limit(format!("WordDocument stream is {} bytes", word.len())));
|
||||
@@ -168,13 +259,14 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
let ftn_base = fib.ccp.text;
|
||||
let hdd_base = fib.ccp.text.saturating_add(fib.ccp.ftn);
|
||||
let edn_base = hdd_base.saturating_add(fib.ccp.hdd).saturating_add(fib.ccp.atn);
|
||||
let mut pics = media::Pictures::default();
|
||||
let mut pics = media::Pictures::for_input(word.len().saturating_add(data.len()));
|
||||
// Every materialised character comes from at least one byte of the WordDocument stream, so a
|
||||
// legitimate document's total text (body plus every header, footnote and text box, each read
|
||||
// once) cannot exceed its stream. A hostile piece or story table that aliases the same bytes
|
||||
// many times can, so the walk is held to twice the stream's size — generous for real files,
|
||||
// tight against aliasing — and never past the absolute ceiling.
|
||||
let budget = std::cell::Cell::new((word.len() as u64).saturating_mul(2).min(MAX_TOTAL_CHARS));
|
||||
let work = Work::new(MAX_WORK);
|
||||
|
||||
let ctx = WalkCtx {
|
||||
word: &word,
|
||||
@@ -190,6 +282,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
drawn: &[],
|
||||
sect_ends: &[],
|
||||
budget: &budget,
|
||||
work: &work,
|
||||
};
|
||||
|
||||
// Drawings: text box stories become parts first, then the floating shapes and pictures
|
||||
@@ -298,6 +391,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result<Docume
|
||||
doc.media.extend(pics.media);
|
||||
doc.body = table::assemble(paras);
|
||||
doc.ensure_nonempty();
|
||||
work.log();
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
@@ -334,6 +428,8 @@ struct WalkCtx<'a> {
|
||||
/// footnotes, text boxes) are hostile and can each span a whole subdocument, so thousands of
|
||||
/// them could otherwise materialise the same text thousands of times; this bounds the total.
|
||||
budget: &'a std::cell::Cell<u64>,
|
||||
/// Repeated-work bytes left ([`MAX_WORK`]), and what they left out.
|
||||
work: &'a Work,
|
||||
}
|
||||
|
||||
/// State while assembling one paragraph from per-CP formatting.
|
||||
@@ -365,6 +461,14 @@ impl ParaBuild {
|
||||
}
|
||||
self.text.push(c);
|
||||
}
|
||||
|
||||
/// Append `c` to the current run, whose properties the caller knows it matches.
|
||||
fn extend(&mut self, c: char) {
|
||||
if let Some(r) = self.runs.last_mut() {
|
||||
r.len += c.len_utf8();
|
||||
}
|
||||
self.text.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk a CP range, splitting paragraphs at 0x0D/0x07 marks, applying the direct character
|
||||
@@ -376,7 +480,7 @@ impl ParaBuild {
|
||||
/// The range is clamped to the last piece's CP, and unreadable stretches (gaps, truncated
|
||||
/// pieces) are skipped a whole piece at a time, so the work is bounded by the text that
|
||||
/// really exists, not by the CP numbers a file claims.
|
||||
fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -> Vec<ParaOut> {
|
||||
fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -> Vec<ParaOut> {
|
||||
let cp_end = cp_end.min(ctx.pieces.cp_end());
|
||||
let mut out = Vec::new();
|
||||
let mut pb = ParaBuild::default();
|
||||
@@ -385,6 +489,7 @@ fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -
|
||||
// Field assembly state: instruction characters are swallowed, the result is kept until
|
||||
// the end character, then emitted as one object (hyperlinks keep their result text).
|
||||
let mut field: Option<FieldBuild> = None;
|
||||
let mut memo: Option<FmtMemo<'a>> = None;
|
||||
while cp < cp_end {
|
||||
// Bookmark boundaries at this CP (main-document walks only; subdocument walks get
|
||||
// an empty table and `cp` never matches the main-document CPs).
|
||||
@@ -490,7 +595,7 @@ fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -
|
||||
f.depth -= 1;
|
||||
field = Some(f);
|
||||
} else {
|
||||
finish_field(&mut pb, f);
|
||||
finish_field(&mut pb, f, ctx.work);
|
||||
}
|
||||
}
|
||||
cp = cp.saturating_add(1);
|
||||
@@ -502,12 +607,15 @@ fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -
|
||||
let fc_pic = pic_location(ctx.chpx_bins.chpx(ctx.word, fc));
|
||||
match fc_pic.and_then(|at| pics.get(ctx.data, at)) {
|
||||
Some(p) => {
|
||||
// Every anchor of a picture gets its own copy of the alt text; the
|
||||
// copies are charged to the work budget (#3709).
|
||||
let alt = if ctx.work.charge(p.alt.len() as u64, Cost::AltText) { p.alt.clone() } else { String::new() };
|
||||
pb.push('\u{FFFC}', CharProps::default());
|
||||
pb.objects.push(InlineObject::Image {
|
||||
media: p.key,
|
||||
media: p.key.clone(),
|
||||
w: p.w,
|
||||
h: p.h,
|
||||
alt: p.alt,
|
||||
alt,
|
||||
float: Default::default(),
|
||||
crop: p.crop,
|
||||
ole: None,
|
||||
@@ -519,7 +627,12 @@ fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -
|
||||
'\u{8}' => {
|
||||
// Drawn object anchor: the floating shape or picture anchored at this CP.
|
||||
let at = ctx.drawn.partition_point(|(c, _)| *c < cp);
|
||||
// Each copy (a story can be walked more than once) is charged to the work budget
|
||||
// by what a clone copies: a picture's alt text, a group with all its members (#3710).
|
||||
for (_, obj) in ctx.drawn.get(at..).unwrap_or_default().iter().take_while(|(c, _)| *c == cp) {
|
||||
if !ctx.work.charge(object_cost(obj), Cost::FloatingObject) {
|
||||
continue;
|
||||
}
|
||||
pb.push('\u{FFFC}', CharProps::default());
|
||||
pb.objects.push(obj.clone());
|
||||
}
|
||||
@@ -532,16 +645,16 @@ fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -
|
||||
pb.objects.push(InlineObject::NoteRef { kind: *kind, id: *id, custom: String::new() });
|
||||
}
|
||||
}
|
||||
'\t' => push_formatted(&mut pb, cp, '\t', ctx, field.as_mut()),
|
||||
'\u{B}' => push_formatted(&mut pb, cp, '\n', ctx, field.as_mut()),
|
||||
'\u{C}' => push_formatted(&mut pb, cp, '\u{C}', ctx, field.as_mut()),
|
||||
'\u{E}' => push_formatted(&mut pb, cp, '\u{E}', ctx, field.as_mut()),
|
||||
'\u{1E}' => push_formatted(&mut pb, cp, '\u{2011}', ctx, field.as_mut()),
|
||||
'\u{1F}' => push_formatted(&mut pb, cp, '\u{AD}', ctx, field.as_mut()),
|
||||
'\t' => push_formatted(&mut pb, cp, '\t', ctx, field.as_mut(), &mut memo),
|
||||
'\u{B}' => push_formatted(&mut pb, cp, '\n', ctx, field.as_mut(), &mut memo),
|
||||
'\u{C}' => push_formatted(&mut pb, cp, '\u{C}', ctx, field.as_mut(), &mut memo),
|
||||
'\u{E}' => push_formatted(&mut pb, cp, '\u{E}', ctx, field.as_mut(), &mut memo),
|
||||
'\u{1E}' => push_formatted(&mut pb, cp, '\u{2011}', ctx, field.as_mut(), &mut memo),
|
||||
'\u{1F}' => push_formatted(&mut pb, cp, '\u{AD}', ctx, field.as_mut(), &mut memo),
|
||||
// Object anchors, comment references and other special characters are dropped
|
||||
// (or handled by later passes).
|
||||
c if (c as u32) < 0x20 => {}
|
||||
c => push_formatted(&mut pb, cp, c, ctx, field.as_mut()),
|
||||
c => push_formatted(&mut pb, cp, c, ctx, field.as_mut(), &mut memo),
|
||||
}
|
||||
cp = cp.saturating_add(units);
|
||||
}
|
||||
@@ -564,19 +677,44 @@ struct FieldBuild {
|
||||
sep: Option<Vec<(char, CharProps)>>,
|
||||
}
|
||||
|
||||
/// Bytes a clone of `obj` copies: the object itself plus a picture's alt text, a group with all
|
||||
/// its members (#3710).
|
||||
fn object_cost(obj: &InlineObject) -> u64 {
|
||||
let shallow = std::mem::size_of::<InlineObject>() as u64;
|
||||
match obj {
|
||||
InlineObject::Image { alt, .. } => shallow.saturating_add(alt.len() as u64),
|
||||
InlineObject::Group { children, .. } => children.iter().fold(shallow, |n, c| n.saturating_add(object_cost(&c.obj))),
|
||||
_ => shallow,
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit a finished field: HYPERLINK keeps its result text as a linked run; every other
|
||||
/// field becomes one inline object with its cached result.
|
||||
fn finish_field(pb: &mut ParaBuild, f: FieldBuild) {
|
||||
///
|
||||
/// The target is copied once per run of equally formatted result text, not per character,
|
||||
/// and each copy is charged to `work` ([`MAX_WORK`]); once that is spent the rest of the
|
||||
/// result is kept as plain text (#3708).
|
||||
fn finish_field(pb: &mut ParaBuild, f: FieldBuild, work: &Work) {
|
||||
let instr = f.instr.trim().to_string();
|
||||
let result = f.sep.unwrap_or_default();
|
||||
let result_text: String = result.iter().map(|(c, _)| *c).collect();
|
||||
if let Some(target) = hyperlink_target(&instr) {
|
||||
for (c, mut props) in result {
|
||||
props.link = Some(target.clone());
|
||||
pb.push(c, props);
|
||||
let cost = target.len() as u64;
|
||||
let mut run: Option<CharProps> = None;
|
||||
for (c, props) in result {
|
||||
if run.as_ref() == Some(&props) {
|
||||
pb.extend(c);
|
||||
continue;
|
||||
}
|
||||
let mut linked = props.clone();
|
||||
if work.charge(cost, Cost::LinkTarget) {
|
||||
linked.link = Some(target.clone());
|
||||
}
|
||||
pb.push(c, linked);
|
||||
run = Some(props);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let result_text: String = result.iter().map(|(c, _)| *c).collect();
|
||||
pb.push('\u{FFFC}', CharProps::default());
|
||||
pb.objects.push(InlineObject::Field { instr, result: result_text, locked: false, dirty: false, code: false, form: String::new() });
|
||||
}
|
||||
@@ -702,19 +840,28 @@ fn num_of(ilfo: i32) -> Option<u32> {
|
||||
/// plus the piece's Prm, over the paragraph style's base character props. Inside a field,
|
||||
/// the character is recorded into the instruction or the cached result instead of (or as
|
||||
/// well as) the visible text.
|
||||
fn push_formatted(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx, field: Option<&mut FieldBuild>) {
|
||||
fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, field: Option<&mut FieldBuild>, memo: &mut Option<FmtMemo<'a>>) {
|
||||
let fc = ctx.pieces.fc_of_cp(cp).unwrap_or(0);
|
||||
let grpprl = ctx.chpx_bins.chpx(ctx.word, fc);
|
||||
let default;
|
||||
let base = match pb.props.style.as_deref().and_then(|id| ctx.sheet.get(id)) {
|
||||
Some(st) => &st.chr,
|
||||
let prm = ctx.pieces.prm_of(cp);
|
||||
let style = pb.props.style.as_deref();
|
||||
let mut props = match memo.as_ref().filter(|m| std::ptr::eq(m.chpx, grpprl) && m.prm == prm && m.style.as_deref() == style) {
|
||||
Some(m) => m.props.clone(),
|
||||
None => {
|
||||
default = CharProps::default();
|
||||
&default
|
||||
let default;
|
||||
let base = match style.and_then(|id| ctx.sheet.get(id)) {
|
||||
Some(st) => &st.chr,
|
||||
None => {
|
||||
default = CharProps::default();
|
||||
&default
|
||||
}
|
||||
};
|
||||
let mut props = char_props(grpprl, ctx.fonts, base);
|
||||
apply_prm(&mut props, ctx.pieces.piece_prm(cp), ctx.fonts, base, ctx.work);
|
||||
*memo = Some(FmtMemo { chpx: grpprl, prm, style: style.map(str::to_owned), props: props.clone() });
|
||||
props
|
||||
}
|
||||
};
|
||||
let mut props = char_props(grpprl, ctx.fonts, base);
|
||||
apply_prm(&mut props, ctx.pieces.piece_prm(cp), ctx.fonts, base);
|
||||
// A symbol character (Insert › Symbol) is stored as the placeholder U+0028 with
|
||||
// sprmCSymbol carrying the font and the character code ([MS-DOC] §2.6.1).
|
||||
let mut c = c;
|
||||
@@ -739,8 +886,20 @@ fn push_formatted(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx, field: Op
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a piece's Prm (Prm0 short sprm or Prm1 grpprl) to character properties.
|
||||
fn apply_prm(props: &mut CharProps, prm: PrmRef<'_>, fonts: &[String], base: &CharProps) {
|
||||
/// The last character formatting resolved, reused while the CHPX, the piece's Prm and the
|
||||
/// paragraph style stay the same, so a Prm grpprl (up to 64 KB) is interpreted once per
|
||||
/// formatting change, not once per character (#3692).
|
||||
struct FmtMemo<'a> {
|
||||
/// The CHPX grpprl, compared by address: it is a slice of the WordDocument stream.
|
||||
chpx: &'a [u8],
|
||||
prm: u16,
|
||||
style: Option<String>,
|
||||
props: CharProps,
|
||||
}
|
||||
|
||||
/// Apply a piece's Prm (Prm0 short sprm or Prm1 grpprl) to character properties. A Prm1
|
||||
/// grpprl is charged to `work`; once that is spent the grpprl is skipped.
|
||||
fn apply_prm(props: &mut CharProps, prm: PrmRef<'_>, fonts: &[String], base: &CharProps, work: &Work) {
|
||||
match prm {
|
||||
PrmRef::None => {}
|
||||
PrmRef::Sprm0 { isprm, val } => {
|
||||
@@ -753,6 +912,9 @@ fn apply_prm(props: &mut CharProps, prm: PrmRef<'_>, fonts: &[String], base: &Ch
|
||||
}
|
||||
}
|
||||
PrmRef::Grpprl(Some(g)) => {
|
||||
if !work.charge(g.len() as u64, Cost::Formatting) {
|
||||
return;
|
||||
}
|
||||
for prl in sprm::iter(g) {
|
||||
if prl.sgc() == 2 {
|
||||
fmt::apply_char(props, &prl, base, fonts);
|
||||
|
||||
+68
-26
@@ -17,6 +17,24 @@ const OFF_PICMID: usize = 28;
|
||||
const MAX_PICTURES: usize = 5_000;
|
||||
/// Most picture bytes one document copies out of the Data stream, all pictures together.
|
||||
pub(crate) const MAX_MEDIA_BYTES: usize = 256 << 20;
|
||||
/// Most bytes one document decodes pictures into (metafiles inflated, DIB pixels), charged
|
||||
/// per attempt whether it succeeds or not: retained media is capped separately, by
|
||||
/// `MAX_MEDIA_BYTES` (#3711, #3712).
|
||||
const MAX_DECODE_WORK: usize = 512 << 20;
|
||||
/// The decode budget grows with the input: up to this many bytes per byte of the WordDocument
|
||||
/// and Data streams. An uncompressed DIB declares at most 32 bytes of pixels per file byte (1
|
||||
/// bit per pixel, 4 bytes charged each), so 64 passes any such picture. RLE8/RLE4 bitmaps
|
||||
/// reach about 510 times that and rely on the `MAX_DECODE_WORK` floor instead; a decompression
|
||||
/// bomb (ratios around 1e6) still hits the cap.
|
||||
const DECODE_WORK_PER_INPUT_BYTE: usize = 64;
|
||||
/// The most the decode budget grows to, whatever the input.
|
||||
const MAX_DECODE_WORK_CEILING: usize = 2 << 30;
|
||||
|
||||
/// The decode budget for a document whose pictures live in `input` stream bytes, never less
|
||||
/// than `floor`.
|
||||
pub(crate) fn decode_work(input: usize, floor: usize) -> usize {
|
||||
input.saturating_mul(DECODE_WORK_PER_INPUT_BYTE).clamp(floor, floor.max(MAX_DECODE_WORK_CEILING))
|
||||
}
|
||||
|
||||
/// The document's pictures, keyed by their location in the Data stream: anchors that share a
|
||||
/// `fc_pic` share one media entry (or one failure), and the total bytes copied are capped, so a
|
||||
@@ -28,11 +46,17 @@ pub(crate) struct Pictures {
|
||||
by_fc: HashMap<u32, Option<Inline>>,
|
||||
/// BLIP store index → media key (`None`: unreadable, not retried).
|
||||
by_bse: HashMap<u32, Option<String>>,
|
||||
/// (stream, foDelay, record length) of a BLIP record outside the store → media key: store
|
||||
/// entries and inline pictures that alias one record share one decode (`None`: failed, not
|
||||
/// retried). The stream is told apart by its address.
|
||||
by_delay: HashMap<(usize, usize, usize), Option<String>>,
|
||||
/// The media entries, merged into the document at the end.
|
||||
pub(crate) media: BTreeMap<String, Arc<Vec<u8>>>,
|
||||
total: usize,
|
||||
/// The byte cap (`MAX_MEDIA_BYTES`; smaller in tests).
|
||||
pub(crate) limit: usize,
|
||||
/// Decode work left (`MAX_DECODE_WORK`; smaller in tests).
|
||||
pub(crate) work: officeart::Decode,
|
||||
/// Anchors whose picture was dropped (only the first few are logged).
|
||||
dropped: usize,
|
||||
/// Pictures kept as placeholders (formats we can't draw).
|
||||
@@ -44,9 +68,11 @@ impl Default for Pictures {
|
||||
Pictures {
|
||||
by_fc: HashMap::new(),
|
||||
by_bse: HashMap::new(),
|
||||
by_delay: HashMap::new(),
|
||||
media: BTreeMap::new(),
|
||||
total: 0,
|
||||
limit: MAX_MEDIA_BYTES,
|
||||
work: officeart::Decode::new(MAX_DECODE_WORK),
|
||||
dropped: 0,
|
||||
placeholders: 0,
|
||||
}
|
||||
@@ -64,19 +90,24 @@ pub(crate) struct Inline {
|
||||
}
|
||||
|
||||
impl Pictures {
|
||||
/// An empty store whose decode budget scales with the `input` stream bytes (WordDocument
|
||||
/// plus Data) the pictures can come from.
|
||||
pub(crate) fn for_input(input: usize) -> Self {
|
||||
Pictures { work: officeart::Decode::new(decode_work(input, MAX_DECODE_WORK)), ..Pictures::default() }
|
||||
}
|
||||
|
||||
/// The inline picture at `fc_pic` in the Data stream, copying its bytes the first time.
|
||||
/// `None` when the header is malformed, the picture can't be read, or a picture
|
||||
/// count/byte cap is reached.
|
||||
pub(crate) fn get(&mut self, data: &[u8], fc_pic: u32) -> Option<Inline> {
|
||||
if let Some(hit) = self.by_fc.get(&fc_pic) {
|
||||
return hit.clone();
|
||||
pub(crate) fn get(&mut self, data: &[u8], fc_pic: u32) -> Option<&Inline> {
|
||||
if !self.by_fc.contains_key(&fc_pic) {
|
||||
if self.by_fc.len().saturating_add(self.by_bse.len()) >= MAX_PICTURES {
|
||||
return None;
|
||||
}
|
||||
let pic = self.read(data, fc_pic);
|
||||
self.by_fc.insert(fc_pic, pic);
|
||||
}
|
||||
if self.by_fc.len().saturating_add(self.by_bse.len()) >= MAX_PICTURES {
|
||||
return None;
|
||||
}
|
||||
let pic = self.read(data, fc_pic);
|
||||
self.by_fc.insert(fc_pic, pic.clone());
|
||||
pic
|
||||
self.by_fc.get(&fc_pic)?.as_ref()
|
||||
}
|
||||
|
||||
/// Read the inline picture at `fc_pic`: nothing is decoded or copied beyond what is left of
|
||||
@@ -86,16 +117,13 @@ impl Pictures {
|
||||
if room == 0 {
|
||||
return None;
|
||||
}
|
||||
match locate(data, fc_pic, room)? {
|
||||
match locate(data, fc_pic, self)? {
|
||||
Found::Range(pic) => {
|
||||
let bytes = data.get(pic.range.clone()).filter(|b| b.len() <= room)?.to_vec();
|
||||
let key = self.insert(bytes, pic.ext, false)?;
|
||||
Some(Inline { key, w: pic.w, h: pic.h, crop: [0.0; 4], alt: String::new() })
|
||||
}
|
||||
Found::Shape { w, h, blip, props } => {
|
||||
let key = self.insert(blip.bytes, blip.ext, blip.placeholder)?;
|
||||
Some(Inline { key, w, h, crop: props.crop, alt: props.alt })
|
||||
}
|
||||
Found::Shape { w, h, key, props } => Some(Inline { key: key?, w, h, crop: props.crop, alt: props.alt }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,12 +137,26 @@ impl Pictures {
|
||||
return None;
|
||||
}
|
||||
let fbse = bstore.get((pib as usize).checked_sub(1)?)?;
|
||||
let room = self.limit.saturating_sub(self.total);
|
||||
let key = officeart::fbse_blip(fbse, delay, room).and_then(|b| self.insert(b.bytes, b.ext, b.placeholder));
|
||||
let key = self.decode_bse(fbse, delay);
|
||||
self.by_bse.insert(pib, key.clone());
|
||||
key
|
||||
}
|
||||
|
||||
/// Decode a store entry's BLIP, once per delay-stream record however many entries name it.
|
||||
fn decode_bse(&mut self, fbse: &[u8], delay: &[u8]) -> Option<String> {
|
||||
let (rec, at) = officeart::fbse_record(fbse, delay)?;
|
||||
let at = at.map(|(fo, used)| (delay.as_ptr() as usize, fo, used));
|
||||
if let Some(hit) = at.and_then(|at| self.by_delay.get(&at)) {
|
||||
return hit.clone();
|
||||
}
|
||||
let room = self.limit.saturating_sub(self.total);
|
||||
let key = officeart::blip(&rec, room, &mut self.work).and_then(|b| self.insert(b.bytes, b.ext, b.placeholder));
|
||||
if let Some(at) = at {
|
||||
self.by_delay.insert(at, key.clone());
|
||||
}
|
||||
key
|
||||
}
|
||||
|
||||
/// Add picture bytes as a new media entry, within the byte cap.
|
||||
fn insert(&mut self, bytes: Vec<u8>, ext: &str, placeholder: bool) -> Option<String> {
|
||||
let total = self.total.checked_add(bytes.len()).filter(|&t| t <= self.limit)?;
|
||||
@@ -151,7 +193,7 @@ struct Located {
|
||||
/// `OfficeArtInlineSpContainer` (Word 97 and later) whose BLIP is decoded.
|
||||
enum Found {
|
||||
Range(Located),
|
||||
Shape { w: f32, h: f32, blip: officeart::Blip, props: officeart::Props },
|
||||
Shape { w: f32, h: f32, key: Option<String>, props: officeart::Props },
|
||||
}
|
||||
|
||||
/// `PICF.mfpf.mm` values of pictures stored as a shape (`MM_SHAPE`) or a shape with a file
|
||||
@@ -160,8 +202,8 @@ const MM_SHAPE: u16 = 0x0064;
|
||||
const MM_SHAPEFILE: u16 = 0x0066;
|
||||
|
||||
/// Locate the picture at `fc_pic` in the Data stream ([MS-DOC] §2.9.192 PICFAndOfficeArtData);
|
||||
/// a shape's picture is decoded only up to `room` bytes.
|
||||
fn locate(data: &[u8], fc_pic: u32, room: usize) -> Option<Found> {
|
||||
/// the BLIP is decoded once per record and charged to the decode budget.
|
||||
fn locate(data: &[u8], fc_pic: u32, pics: &mut Pictures) -> Option<Found> {
|
||||
let base = fc_pic as usize;
|
||||
let pic = data.get(base..)?;
|
||||
let lcb = u32::from_le_bytes(pic.get(0..4)?.try_into().ok()?) as usize;
|
||||
@@ -180,12 +222,12 @@ fn locate(data: &[u8], fc_pic: u32, room: usize) -> Option<Found> {
|
||||
if mm == MM_SHAPEFILE {
|
||||
at = at.checked_add(1 + *pic.get(at)? as usize)?;
|
||||
}
|
||||
let (blip, props) = inline_shape(pic.get(at..end)?, data, room)?;
|
||||
let (key, props) = inline_shape(pic.get(at..end)?, data, pics)?;
|
||||
// The goal size is before cropping; the crop fractions take their share off it.
|
||||
let [cl, ct, cr, cb] = props.crop;
|
||||
let w = (dxa_goal / 20.0 * (1.0 - cl - cr).max(0.01) * scale(mx)).clamp(1.0, 1584.0);
|
||||
let h = (dya_goal / 20.0 * (1.0 - ct - cb).max(0.01) * scale(my)).clamp(1.0, 1584.0);
|
||||
return Some(Found::Shape { w, h, blip, props });
|
||||
return Some(Found::Shape { w, h, key, props });
|
||||
}
|
||||
let w = (dxa_goal / 20.0 * scale(mx)).clamp(1.0, 1584.0);
|
||||
let h = (dya_goal / 20.0 * scale(my)).clamp(1.0, 1584.0);
|
||||
@@ -197,16 +239,16 @@ fn locate(data: &[u8], fc_pic: u32, room: usize) -> Option<Found> {
|
||||
}
|
||||
|
||||
/// An `OfficeArtInlineSpContainer`: the picture shape, then the `OfficeArtFBSE` records its
|
||||
/// `pib` indexes (BLIPs embedded, or at `foDelay` in the Data stream). `room` caps the bytes
|
||||
/// the BLIP decodes to.
|
||||
fn inline_shape(buf: &[u8], data: &[u8], room: usize) -> Option<(officeart::Blip, officeart::Props)> {
|
||||
/// `pib` indexes (BLIPs embedded, or at `foDelay` in the Data stream). The BLIP is decoded
|
||||
/// once per record, however many pictures (and FBSE sizes) name it.
|
||||
fn inline_shape(buf: &[u8], data: &[u8], pics: &mut Pictures) -> Option<(Option<String>, officeart::Props)> {
|
||||
let mut budget = 10_000usize;
|
||||
let recs = officeart::children(buf, &mut budget);
|
||||
let sp = recs.iter().find(|r| r.typ == officeart::rt::SP_CONTAINER).map(|r| officeart::shape(r.body, &mut budget))?;
|
||||
let fbses: Vec<&[u8]> = recs.iter().filter(|r| r.typ == officeart::rt::FBSE).map(|r| r.body).collect();
|
||||
let fbse = sp.props.pib.and_then(|p| fbses.get((p as usize).checked_sub(1)?)).or(fbses.first())?;
|
||||
let blip = officeart::fbse_blip(fbse, data, room)?;
|
||||
Some((blip, sp.props))
|
||||
let key = pics.decode_bse(fbse, data);
|
||||
Some((key, sp.props))
|
||||
}
|
||||
|
||||
/// Offset of the first embedded raster image inside the payload, skipping the small
|
||||
|
||||
@@ -112,23 +112,65 @@ pub(crate) struct Blip {
|
||||
/// The picture of an `OfficeArtFBSE` body: its embedded BLIP, or the BLIP stored at `foDelay`
|
||||
/// in `delay` (the WordDocument stream for the document's BLIP store, the Data stream for
|
||||
/// inline pictures). `limit` caps the bytes produced.
|
||||
pub(crate) fn fbse_blip(fbse: &[u8], delay: &[u8], limit: usize) -> Option<Blip> {
|
||||
#[cfg(test)]
|
||||
pub(crate) fn fbse_blip(fbse: &[u8], delay: &[u8], limit: usize, work: &mut Decode) -> Option<Blip> {
|
||||
let (rec, _) = fbse_record(fbse, delay)?;
|
||||
blip(&rec, limit, work)
|
||||
}
|
||||
|
||||
/// The document's decode budget: bytes of inflating and pixel decoding left, and how many
|
||||
/// attempts it has refused (only the first few are logged).
|
||||
pub(crate) struct Decode {
|
||||
pub(crate) left: usize,
|
||||
refused: usize,
|
||||
}
|
||||
|
||||
impl Decode {
|
||||
pub(crate) fn new(left: usize) -> Self {
|
||||
Decode { left, refused: 0 }
|
||||
}
|
||||
|
||||
/// Take `cost` off the budget; `false` (and a log line) when it doesn't fit.
|
||||
fn charge(&mut self, cost: usize, what: &str) -> bool {
|
||||
match self.left.checked_sub(cost) {
|
||||
Some(left) if cost > 0 => {
|
||||
self.left = left;
|
||||
true
|
||||
}
|
||||
_ => {
|
||||
if self.refused < 10 {
|
||||
log::warn!(
|
||||
"docbin: a {what} needing {cost} bytes of decode work is over the decode budget ({} left) and was not decoded",
|
||||
self.left
|
||||
);
|
||||
}
|
||||
self.refused = self.refused.saturating_add(1);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The BLIP record of an `OfficeArtFBSE`: embedded after it, or in the delay stream, where
|
||||
/// its (`foDelay`, record length) location is returned too (entries can share one record,
|
||||
/// whatever sizes their FBSEs claim: the record's own header is what counts).
|
||||
pub(crate) fn fbse_record<'a>(fbse: &'a [u8], delay: &'a [u8]) -> Option<(Rec<'a>, Option<(usize, usize)>)> {
|
||||
// btWin32, btMacOS, rgbUid[16], tag, size, cRef, foDelay, unused, cbName, unused×2.
|
||||
let size = u32_at(fbse, 20)? as usize;
|
||||
let fo_delay = u32_at(fbse, 28)? as usize;
|
||||
let cb_name = *fbse.get(33)? as usize;
|
||||
let at = 36usize.checked_add(cb_name)?;
|
||||
if let Some((rec, _)) = fbse.get(at..).and_then(record) {
|
||||
return blip(&rec, limit);
|
||||
return Some((rec, None));
|
||||
}
|
||||
// Not embedded: the record sits in the delay stream.
|
||||
let end = fo_delay.checked_add(size.max(8))?.min(delay.len());
|
||||
let (rec, _) = record(delay.get(fo_delay..end)?)?;
|
||||
blip(&rec, limit)
|
||||
let (rec, used) = record(delay.get(fo_delay..)?)?;
|
||||
Some((rec, Some((fo_delay, used))))
|
||||
}
|
||||
|
||||
/// Decode one `OfficeArtBlip*` record ([MS-ODRAW] §2.2.23-§2.2.31).
|
||||
pub(crate) fn blip(rec: &Rec, limit: usize) -> Option<Blip> {
|
||||
/// Decode one `OfficeArtBlip*` record ([MS-ODRAW] §2.2.23-§2.2.31). `work` is the
|
||||
/// document's decode budget: inflating a metafile or decoding a DIB is charged to it before
|
||||
/// it runs, whether it succeeds or not, and nothing is decoded once it is spent.
|
||||
pub(crate) fn blip(rec: &Rec, limit: usize, work: &mut Decode) -> Option<Blip> {
|
||||
// Each type has two instance values; the odd one carries a second 16-byte UID.
|
||||
let uids = if rec.inst & 1 == 1 { 32 } else { 16 };
|
||||
match rec.typ {
|
||||
@@ -141,7 +183,7 @@ pub(crate) fn blip(rec: &Rec, limit: usize) -> Option<Blip> {
|
||||
rt::BLIP_PNG => Some(Blip { bytes: data.to_vec(), ext: "png", placeholder: false }),
|
||||
rt::BLIP_JPEG | rt::BLIP_JPEG_CMYK => Some(Blip { bytes: data.to_vec(), ext: "jpeg", placeholder: false }),
|
||||
rt::BLIP_TIFF => Some(Blip { bytes: data.to_vec(), ext: "tiff", placeholder: false }),
|
||||
_ => dib_to_png(data, limit),
|
||||
_ => dib_to_png(data, limit, work),
|
||||
}
|
||||
}
|
||||
rt::BLIP_EMF | rt::BLIP_WMF | rt::BLIP_PICT => {
|
||||
@@ -155,6 +197,11 @@ pub(crate) fn blip(rec: &Rec, limit: usize) -> Option<Blip> {
|
||||
let cap = limit.min(MAX_METAFILE);
|
||||
let bytes = if compression == 0 {
|
||||
let want = if cb_size > 0 { cb_size.min(cap) } else { cap };
|
||||
// The compressed stream is read whole whatever it inflates to: a record that
|
||||
// claims to inflate to nothing still costs its input (#3871).
|
||||
if !work.charge(want.max(raw.len()), "metafile") {
|
||||
return None;
|
||||
}
|
||||
miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(raw, want).ok()?
|
||||
} else {
|
||||
if raw.len() > cap {
|
||||
@@ -177,8 +224,9 @@ pub(crate) fn blip(rec: &Rec, limit: usize) -> Option<Blip> {
|
||||
|
||||
/// A device-independent bitmap (BITMAPINFOHEADER + palette + pixels) as a PNG: the DIB gets
|
||||
/// the 14-byte BMP file header it lacks, is decoded with size limits and re-encoded. When the
|
||||
/// bitmap can't be decoded the BMP bytes are kept (and render as a grey placeholder).
|
||||
fn dib_to_png(dib: &[u8], limit: usize) -> Option<Blip> {
|
||||
/// bitmap can't be decoded, or its declared pixels (4 bytes each) don't fit in `work`, the
|
||||
/// BMP bytes are kept (and render as a grey placeholder).
|
||||
fn dib_to_png(dib: &[u8], limit: usize, work: &mut Decode) -> Option<Blip> {
|
||||
let header = u32_at(dib, 0)? as usize;
|
||||
if !(12..=124).contains(&header) {
|
||||
return None;
|
||||
@@ -197,12 +245,30 @@ fn dib_to_png(dib: &[u8], limit: usize) -> Option<Blip> {
|
||||
let masks = if header == 40 && (compression == 3 || compression == 6) { if compression == 3 { 12 } else { 16 } } else { 0 };
|
||||
let off_bits = 14usize.checked_add(header)?.checked_add(colors * entry)?.checked_add(masks)?;
|
||||
let file_size = 14usize.checked_add(dib.len()).filter(|&size| size <= limit)?;
|
||||
// The declared size is what decoding allocates, however small the (RLE) data is.
|
||||
let (w, h) = if header == 12 {
|
||||
(u64::from(u16_at(dib, 4)?), u64::from(u16_at(dib, 6)?))
|
||||
} else {
|
||||
(u64::from(u32_at(dib, 4)?), u64::from((u32_at(dib, 8)? as i32).unsigned_abs()))
|
||||
};
|
||||
let cost = w.saturating_mul(h).saturating_mul(4).try_into().unwrap_or(usize::MAX);
|
||||
// Copying the DIB into a BMP is work too, however few pixels it declares: it is charged
|
||||
// before the copy is made (#3871). A bitmap whose pixels don't fit still keeps its BMP
|
||||
// bytes as a placeholder, if the copy fits.
|
||||
let decode = work.charge(cost.max(dib.len()), "bitmap");
|
||||
if !decode && !work.charge(dib.len(), "bitmap") {
|
||||
return None;
|
||||
}
|
||||
let mut bmp = Vec::with_capacity(file_size);
|
||||
bmp.extend_from_slice(b"BM");
|
||||
bmp.extend_from_slice(&u32::try_from(file_size).ok()?.to_le_bytes());
|
||||
bmp.extend_from_slice(&[0, 0, 0, 0]);
|
||||
bmp.extend_from_slice(&u32::try_from(off_bits).ok()?.to_le_bytes());
|
||||
bmp.extend_from_slice(dib);
|
||||
let placeholder = |bmp| Some(Blip { bytes: bmp, ext: "bmp", placeholder: true });
|
||||
if !decode {
|
||||
return placeholder(bmp);
|
||||
}
|
||||
let mut reader = image::ImageReader::new(std::io::Cursor::new(&bmp));
|
||||
reader.set_format(image::ImageFormat::Bmp);
|
||||
let mut limits = image::Limits::default();
|
||||
@@ -217,7 +283,7 @@ fn dib_to_png(dib: &[u8], limit: usize) -> Option<Blip> {
|
||||
{
|
||||
Some(Blip { bytes: png.bytes, ext: "png", placeholder: false })
|
||||
}
|
||||
_ => Some(Blip { bytes: bmp, ext: "bmp", placeholder: true }),
|
||||
_ => placeholder(bmp),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,21 +377,15 @@ impl Props {
|
||||
}
|
||||
0x0104 => self.pib = Some(op),
|
||||
0x0181 => self.fill_color = color(op),
|
||||
0x01BF => {
|
||||
// fFilled (bit 4) is valid when fUsefFilled (bit 20) is set.
|
||||
if op & (1 << 20) != 0 {
|
||||
self.filled = Some(op & (1 << 4) != 0);
|
||||
}
|
||||
}
|
||||
// fFilled (bit 4) is valid when fUsefFilled (bit 20) is set.
|
||||
0x01BF if op & (1 << 20) != 0 => self.filled = Some(op & (1 << 4) != 0),
|
||||
0x01BF => {}
|
||||
0x01C0 => self.line_color = color(op),
|
||||
0x01CB => self.line_width = Some(op),
|
||||
0x01D0 | 0x01D1 => self.arrowhead |= op != 0,
|
||||
0x01FF => {
|
||||
// fLine (bit 3) is valid when fUsefLine (bit 19) is set.
|
||||
if op & (1 << 19) != 0 {
|
||||
self.line = Some(op & (1 << 3) != 0);
|
||||
}
|
||||
}
|
||||
// fLine (bit 3) is valid when fUsefLine (bit 19) is set.
|
||||
0x01FF if op & (1 << 19) != 0 => self.line = Some(op & (1 << 3) != 0),
|
||||
0x01FF => {}
|
||||
0x0384..=0x0387 => self.wrap_dist[(pid - 0x0384) as usize] = Some(op),
|
||||
0x038F => self.posh = Some(op),
|
||||
0x0390 => self.posrelh = Some(op),
|
||||
|
||||
@@ -113,10 +113,16 @@ impl PieceTable {
|
||||
}
|
||||
PrmRef::Sprm0 { isprm, val: (p.prm >> 8) as u8 }
|
||||
} else {
|
||||
PrmRef::Grpprl(self.prc_grpprls.get((p.prm >> 1) as usize).map(|v| v.as_slice()))
|
||||
PrmRef::Grpprl(self.prc_grpprls.get((p.prm >> 1) as usize).map(Vec::as_slice))
|
||||
}
|
||||
}
|
||||
|
||||
/// The raw Prm of the piece holding `cp` (0, no properties, when there is none): two CPs
|
||||
/// with the same value get the same [`PrmRef`].
|
||||
pub(crate) fn prm_of(&self, cp: u32) -> u16 {
|
||||
self.piece_of(cp).map_or(0, |p| p.prm)
|
||||
}
|
||||
|
||||
fn piece_of(&self, cp: u32) -> Option<&Pcd> {
|
||||
let i = self.cps.partition_point(|&c| c <= cp).checked_sub(1)?;
|
||||
if i >= self.pieces.len() {
|
||||
|
||||
+179
-4
@@ -1732,9 +1732,9 @@ fn picture_bytes_are_capped_per_document() {
|
||||
let data: Vec<u8> = [a.clone(), b].concat();
|
||||
let mut pics = crate::media::Pictures::default();
|
||||
pics.limit = 1000;
|
||||
let first = pics.get(&data, 0).expect("first fits");
|
||||
let first = pics.get(&data, 0).cloned().expect("first fits");
|
||||
// Same location again: no new bytes.
|
||||
assert_eq!(pics.get(&data, 0).map(|p| p.key), Some(first.key.clone()));
|
||||
assert_eq!(pics.get(&data, 0).map(|p| p.key.clone()), Some(first.key.clone()));
|
||||
// A second distinct picture would exceed the cap.
|
||||
assert!(pics.get(&data, a.len() as u32).is_none());
|
||||
assert_eq!(pics.media.len(), 1);
|
||||
@@ -1754,8 +1754,8 @@ fn rejected_inline_pictures_are_not_read_again() {
|
||||
assert!(pics.get(&small, 0).is_none());
|
||||
// The rejected picture took nothing off the cap, and anchors sharing a picture share it.
|
||||
let at = big.len() as u32;
|
||||
let fits = pics.get(&data, at).expect("fits");
|
||||
assert_eq!(pics.get(&data, at).map(|p| p.key), Some(fits.key));
|
||||
let fits = pics.get(&data, at).cloned().expect("fits");
|
||||
assert_eq!(pics.get(&data, at).map(|p| p.key.clone()), Some(fits.key));
|
||||
assert_eq!(pics.media.len(), 1);
|
||||
// Filling the cap exactly works; after that, a place never tried before isn't read.
|
||||
let rest = picf_with_png(700);
|
||||
@@ -2392,3 +2392,178 @@ fn paragraph_borders_read_from_both_forms() {
|
||||
assert_eq!((top.style, top.width, top.space), (wordcraft_doc::props::BorderStyle::Single, 1.0, 2.0));
|
||||
assert_eq!(top.color, Some(wordcraft_doc::Rgb(0xFF, 0, 0)));
|
||||
}
|
||||
|
||||
/// #3642: a small compound file whose DIFAT names one FAT sector thousands of times made `cfb`
|
||||
/// build a FAT of hundreds of megabytes before checking it. The precheck refuses it first.
|
||||
#[test]
|
||||
fn a_difat_repeating_one_fat_sector_is_refused_before_cfb_builds_the_fat() {
|
||||
let f = wordcraft_doc::cfb_guard::repeating_difat_sample();
|
||||
// The precheck's own error is the proof that it ran before cfb (no clock to skew).
|
||||
assert!(matches!(read(&f), Err(super::DocbinError::Container(e)) if e.contains("FAT sectors in a file of")));
|
||||
assert!(!super::is_encrypted(&f));
|
||||
// A file cfb wrote itself passes.
|
||||
assert!(wordcraft_doc::cfb_guard::check(&cfb_file(&[("WordDocument", &[0u8; 4096])])).is_ok());
|
||||
}
|
||||
|
||||
/// A compound file `cfb` writes in `version`: `streams` at its root, then `extra` streams under
|
||||
/// an `ObjectPool` storage (where Word keeps embedded objects).
|
||||
fn cfb_file_in(version: cfb::Version, streams: &[(&str, &[u8])], extra: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut comp = cfb::CompoundFile::create_with_version(version, std::io::Cursor::new(Vec::new())).expect("cfb create");
|
||||
for (name, data) in streams {
|
||||
comp.create_stream(format!("/{name}")).expect("cfb stream").write_all(data).expect("cfb write");
|
||||
}
|
||||
comp.create_storage("/ObjectPool").expect("cfb storage");
|
||||
for (name, data) in extra {
|
||||
comp.create_stream(format!("/ObjectPool/{name}")).expect("cfb stream").write_all(data).expect("cfb write");
|
||||
}
|
||||
comp.into_inner().into_inner()
|
||||
}
|
||||
|
||||
/// #3642 review: valid compound files pass the guard and open, so it refuses only the hostile
|
||||
/// ones. The repository keeps no files Word made, so these are written by the `cfb` crate in
|
||||
/// the shape of Word 97–2003 files: the WordDocument and table streams of a .doc these tests
|
||||
/// build, with the summary, Data and embedded-object streams Word adds, in 512-byte sectors
|
||||
/// (version 3, Word's own) and 4096-byte ones (version 4); and one with a Data stream of 8 MB,
|
||||
/// past what the header's 109 FAT sector slots map in 512-byte sectors (109 × 128 sectors,
|
||||
/// about 7 MB), so its FAT sectors are listed in a DIFAT chain too, which the guard walks.
|
||||
#[test]
|
||||
fn valid_doc_shaped_compound_files_pass_the_guard_and_open() {
|
||||
let doc = struct_doc(&structured_spec("Hello world\r", 12));
|
||||
let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(doc.as_slice())).expect("cfb open");
|
||||
// The table stream is 1Table or 0Table, as the FIB's fWhichTblStm says: kept under its name.
|
||||
let table_name = if comp.is_stream("/1Table") { "1Table" } else { "0Table" };
|
||||
let mut stream = |name: &str| {
|
||||
let mut v = Vec::new();
|
||||
std::io::Read::read_to_end(&mut comp.open_stream(name).expect("stream"), &mut v).expect("read");
|
||||
v
|
||||
};
|
||||
let (word, table) = (stream("/WordDocument"), stream(&format!("/{table_name}")));
|
||||
let summary = vec![0xFEu8; 4096];
|
||||
let data = |n: usize| (0..n).map(|i| (i % 251) as u8).collect::<Vec<u8>>();
|
||||
let small = data(200_000);
|
||||
let big = data(8 << 20);
|
||||
let ole = data(70_000);
|
||||
let files = [
|
||||
("as built", doc.clone()),
|
||||
(
|
||||
"version 3",
|
||||
cfb_file_in(
|
||||
cfb::Version::V3,
|
||||
&[("WordDocument", &word), (table_name, &table), ("Data", &small), ("\u{5}SummaryInformation", &summary)],
|
||||
&[("_1234567890", &ole)],
|
||||
),
|
||||
),
|
||||
("version 4", cfb_file_in(cfb::Version::V4, &[("WordDocument", &word), (table_name, &table), ("Data", &small)], &[("_1", &ole)])),
|
||||
("with a DIFAT chain", cfb_file_in(cfb::Version::V3, &[("WordDocument", &word), (table_name, &table), ("Data", &big)], &[])),
|
||||
];
|
||||
let difat_sectors = |f: &[u8]| f.get(0x48..0x4C).map_or(0, |b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]));
|
||||
assert!(difat_sectors(&files[3].1) > 0, "the 8 MB file lists FAT sectors past the header's");
|
||||
for (what, f) in &files {
|
||||
assert_eq!(wordcraft_doc::cfb_guard::check(f), Ok(()), "{what}");
|
||||
let doc = read(f).unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
assert_eq!(body_texts(&doc).first().map(String::as_str), Some("Hello world"), "{what}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #3708: a long HYPERLINK target over a long result was copied (and compared) once per result
|
||||
/// character. It is now copied once per run.
|
||||
#[test]
|
||||
fn a_long_hyperlink_over_a_long_result_is_one_linked_run() {
|
||||
let n = 250_000;
|
||||
let target = "x".repeat(n);
|
||||
let text: &'static str = Box::leak(format!("\u{13}HYPERLINK \"http://{target}\"\u{14}{}\u{15}\r", "y".repeat(n)).into_boxed_str());
|
||||
let spec = structured_spec(text, text.chars().count() as u32);
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
|
||||
let linked: Vec<_> = p0.runs.iter().filter(|r| r.props.link.is_some()).collect();
|
||||
assert_eq!(linked.len(), 1);
|
||||
assert_eq!(linked.first().map(|r| r.len), Some(n));
|
||||
// One copy of the target for the one run, not one per character (~2.5 s here): counted by
|
||||
// what the copies charge to the work budget, which a busy machine can't skew.
|
||||
let result = vec![('y', wordcraft_doc::props::CharProps::default()); n];
|
||||
let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"http://{target}\""), sep: Some(result) };
|
||||
let work = super::Work::new(u64::MAX);
|
||||
super::finish_field(&mut super::ParaBuild::default(), f, &work);
|
||||
assert_eq!(u64::MAX - work.left.get(), ("http://".len() + n) as u64, "one copy of the target");
|
||||
}
|
||||
|
||||
/// #3708: a result whose formatting changes every character pays for a target copy per run
|
||||
/// out of the work budget; past it the text stays, unlinked.
|
||||
#[test]
|
||||
fn hyperlink_target_copies_are_charged_to_the_work_budget() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
let bold = CharProps { bold: Some(true), ..Default::default() };
|
||||
let result: Vec<(char, CharProps)> = (0..1000).map(|i| ('a', if i % 2 == 0 { bold.clone() } else { CharProps::default() })).collect();
|
||||
let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"{}\"", "u".repeat(100)), sep: Some(result) };
|
||||
let mut pb = super::ParaBuild::default();
|
||||
let budget = super::Work::new(10_000);
|
||||
super::finish_field(&mut pb, f, &budget);
|
||||
assert_eq!(pb.text.len(), 1000, "all text kept");
|
||||
let linked: usize = pb.runs.iter().filter_map(|r| r.props.link.as_ref()).map(|l| l.len()).sum();
|
||||
assert!(linked <= 10_000, "linked bytes {linked}");
|
||||
assert_eq!(pb.runs.iter().filter(|r| r.props.link.is_some()).count(), 100);
|
||||
assert_eq!(budget.left.get(), 0);
|
||||
assert_eq!(budget.dropped(), [super::Cost::LinkTarget], "the unlinked text is noted, to be logged");
|
||||
}
|
||||
|
||||
/// #3871 review: what the work budget leaves out is noted once per kind, in a fixed order, and
|
||||
/// logged once per kind when the document has been read; nothing is noted while it lasts.
|
||||
#[test]
|
||||
fn the_work_budget_notes_each_kind_it_leaves_out_once() {
|
||||
use super::Cost::*;
|
||||
let work = super::Work::new(10);
|
||||
assert!(work.charge(10, AltText));
|
||||
assert!(work.dropped().is_empty(), "spent to the last byte, nothing left out");
|
||||
assert!(work.charge(0, LinkTarget), "a free copy is never refused");
|
||||
for _ in 0..3 {
|
||||
assert!(!work.charge(1, FloatingObject));
|
||||
}
|
||||
assert!(!work.charge(1, AltText));
|
||||
assert_eq!(work.dropped(), [AltText, FloatingObject]);
|
||||
work.log();
|
||||
assert_eq!(super::LAST_DROPPED.with(|d| d.borrow().clone()), [AltText, FloatingObject]);
|
||||
// A document within the budget leaves nothing out.
|
||||
read(&struct_doc(&structured_spec("Hello\r", 6))).expect("opens");
|
||||
assert!(super::LAST_DROPPED.with(|d| d.borrow().is_empty()));
|
||||
}
|
||||
|
||||
/// #3692: a piece's Prm grpprl (a Prc of up to 64 KB) was re-interpreted for every character.
|
||||
/// It is now resolved once per formatting change.
|
||||
#[test]
|
||||
fn a_large_prc_is_interpreted_once_per_formatting_change() {
|
||||
let prc = grpprl(&vec![(0x0835u16, &[0x01u8][..]); 21_000]);
|
||||
let text: &'static str = Box::leak(format!("{}\r", "a".repeat(100_000)).into_boxed_str());
|
||||
let spec = RichSpec {
|
||||
text,
|
||||
styles: vec![(1, 0x0FFF, "Normal", Vec::new(), Vec::new())],
|
||||
fonts: vec!["Times New Roman"],
|
||||
chpx_runs: vec![(0, Vec::new())],
|
||||
papx_entries: vec![(0, 0, Vec::new())],
|
||||
};
|
||||
let bytes = rich_doc_with(&spec, &RichExtra { prcs: vec![prc], prm: 0x0001, ..Default::default() });
|
||||
let t = std::time::Instant::now();
|
||||
let doc = read(&bytes).expect("opens");
|
||||
// About 60 ms on a loaded machine; interpreting the Prc per character took 88 s.
|
||||
assert!(t.elapsed().as_secs() < 10, "opened in {:?}", t.elapsed());
|
||||
let p = para0(&doc);
|
||||
assert_eq!(p.runs.len(), 1);
|
||||
assert_eq!(p.runs.first().and_then(|r| r.props.bold), Some(true));
|
||||
}
|
||||
|
||||
/// #3692: Prm grpprls are charged to a document-wide work budget; once it is spent the Prm
|
||||
/// is skipped (the text keeps its CHPX formatting).
|
||||
#[test]
|
||||
fn prm_grpprls_past_the_work_budget_are_skipped() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
let g = grpprl(&[(0x0835, &[0x01])]);
|
||||
let work = super::Work::new(g.len() as u64 + 1);
|
||||
let base = CharProps::default();
|
||||
let mut first = CharProps::default();
|
||||
super::apply_prm(&mut first, crate::piece::PrmRef::Grpprl(Some(&g)), &[], &base, &work);
|
||||
assert_eq!(first.bold, Some(true));
|
||||
let mut second = CharProps::default();
|
||||
super::apply_prm(&mut second, crate::piece::PrmRef::Grpprl(Some(&g)), &[], &base, &work);
|
||||
assert_eq!(second.bold, None);
|
||||
assert_eq!(work.left.get(), 1);
|
||||
assert_eq!(work.dropped(), [super::Cost::Formatting]);
|
||||
}
|
||||
|
||||
@@ -247,7 +247,7 @@ fn dib_becomes_png_and_compressed_metafiles_inflate() {
|
||||
dib.extend_from_slice(&[0, 0, 255, 0, 255, 0, 0, 0]);
|
||||
let r = rec(0, 0x7A8, 0xF01F, &[[0u8; 16].as_slice(), &[0xFF], &dib].concat());
|
||||
let (dib_rec, _) = officeart::record(&r).unwrap();
|
||||
let blip = officeart::blip(&dib_rec, 1 << 20).expect("DIB decodes");
|
||||
let blip = officeart::blip(&dib_rec, 1 << 20, &mut officeart::Decode::new(usize::MAX)).expect("DIB decodes");
|
||||
assert_eq!(blip.ext, "png");
|
||||
let img = image::load_from_memory(&blip.bytes).unwrap().to_rgb8();
|
||||
assert_eq!((img.width(), img.height(), img.get_pixel(0, 0).0), (2, 1, [255, 0, 0]));
|
||||
@@ -262,7 +262,7 @@ fn dib_becomes_png_and_compressed_metafiles_inflate() {
|
||||
hdr.extend_from_slice(&[0x00, 0xFE]); // compression = deflate, filter
|
||||
let r = rec(0, 0x216, 0xF01B, &[[0u8; 16].as_slice(), &hdr, &packed].concat());
|
||||
let (wmf_rec, _) = officeart::record(&r).unwrap();
|
||||
let blip = officeart::blip(&wmf_rec, 1 << 20).expect("WMF inflates");
|
||||
let blip = officeart::blip(&wmf_rec, 1 << 20, &mut officeart::Decode::new(usize::MAX)).expect("WMF inflates");
|
||||
assert_eq!((blip.ext, blip.bytes), ("wmf", wmf));
|
||||
}
|
||||
|
||||
@@ -281,10 +281,10 @@ fn deeply_nested_groups_and_lying_lengths_are_bounded() {
|
||||
hdr.extend_from_slice(&[0, 0]);
|
||||
let r = rec(0, 0x3D4, 0xF01A, &[[0u8; 16].as_slice(), &hdr, &[0x78, 0x9C, 1, 2, 3]].concat());
|
||||
let (emf, _) = officeart::record(&r).unwrap();
|
||||
assert!(officeart::blip(&emf, 1 << 20).is_none());
|
||||
assert!(officeart::blip(&emf, 1 << 20, &mut officeart::Decode::new(usize::MAX)).is_none());
|
||||
let mut fbse = vec![0u8; 36];
|
||||
fbse[28..32].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert!(officeart::fbse_blip(&fbse, &[0u8; 64], 1 << 20).is_none());
|
||||
assert!(officeart::fbse_blip(&fbse, &[0u8; 64], 1 << 20, &mut officeart::Decode::new(usize::MAX)).is_none());
|
||||
}
|
||||
|
||||
proptest! {
|
||||
@@ -322,13 +322,296 @@ fn dib_conversion_caps_header_and_encoded_bytes() {
|
||||
let record = rec(0, 0x7A8, officeart::rt::BLIP_DIB, &[[0u8; 16].as_slice(), &[0xFF], &dib].concat());
|
||||
let (record, _) = officeart::record(&record).unwrap();
|
||||
let bmp_size = 14 + dib.len();
|
||||
assert!(officeart::blip(&record, bmp_size - 1).is_none(), "the BMP header also needs room");
|
||||
let fallback = officeart::blip(&record, bmp_size).expect("the BMP fallback fits exactly");
|
||||
assert!(officeart::blip(&record, bmp_size - 1, &mut officeart::Decode::new(usize::MAX)).is_none(), "the BMP header also needs room");
|
||||
let fallback = officeart::blip(&record, bmp_size, &mut officeart::Decode::new(usize::MAX)).expect("the BMP fallback fits exactly");
|
||||
assert_eq!((fallback.ext, fallback.placeholder, fallback.bytes.len()), ("bmp", true, bmp_size));
|
||||
let img = image::load_from_memory(&fallback.bytes).unwrap().to_rgb8();
|
||||
assert_eq!((img.width(), img.height(), img.get_pixel(0, 0).0), (1, 1, [90, 60, 30]));
|
||||
let normal = officeart::blip(&record, 512).expect("normal conversion fits");
|
||||
let normal = officeart::blip(&record, 512, &mut officeart::Decode::new(usize::MAX)).expect("normal conversion fits");
|
||||
assert_eq!((normal.ext, normal.placeholder), ("png", false));
|
||||
assert!(normal.bytes.len() > bmp_size && normal.bytes.len() <= 512);
|
||||
assert_eq!(image::load_from_memory(&normal.bytes).unwrap().width(), 1);
|
||||
}
|
||||
|
||||
/// #3709: every anchor of one inline picture got its own copy of the alt text (up to 4096
|
||||
/// UTF-16 units). Copies are charged to the document's work budget; past it alt is empty.
|
||||
#[test]
|
||||
fn alt_text_copies_across_many_anchors_are_bounded() {
|
||||
let alt: String = "€".repeat(4096); // 12 KB as UTF-8
|
||||
let shape = sp(&[fsp(75, 1025, 0xA00), fopt(&[(0x0104, 1)], &[(0x0381, wz(&alt))])]);
|
||||
let data = picf_shape(&[shape, fbse_png(&tiny_png())].concat());
|
||||
let text: &'static str = Box::leak(format!("{}\r", "\u{1}".repeat(8000)).into_boxed_str());
|
||||
let ccp = text.chars().count() as u32;
|
||||
let spec = StructSpec { text, data_stream: data, chpx_runs: vec![(0, grpprl(&[(0x6A03, &0u32.to_le_bytes())]))], ..structured_spec(text, ccp) };
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
|
||||
let alts: Vec<&str> = p0.objects.iter().filter_map(|o| if let InlineObject::Image { alt, .. } = o { Some(alt.as_str()) } else { None }).collect();
|
||||
assert_eq!(alts.len(), 8000, "every anchor keeps its picture");
|
||||
assert_eq!(alts.first().copied(), Some(alt.as_str()));
|
||||
let total: usize = alts.iter().map(|a| a.len()).sum();
|
||||
assert!(total <= 64 << 20, "alt bytes {total}");
|
||||
assert_eq!(alts.last().copied(), Some(""));
|
||||
// Leaving it out is logged, once (#3871 review).
|
||||
assert_eq!(crate::LAST_DROPPED.with(|d| d.borrow().clone()), [crate::Cost::AltText]);
|
||||
}
|
||||
|
||||
/// #3710: every PlcSpa entry naming a group built another copy of all its members. A shape is
|
||||
/// anchored once; repeated spids are skipped.
|
||||
#[test]
|
||||
fn a_group_named_by_many_anchors_is_built_once() {
|
||||
let rect = |l: i32| rec(0, 0, 0xF00F, &[l, 0, l + 10, 10].iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<_>>());
|
||||
let head = sp(&[fsp(0, 3000, 0x201), rec(1, 0, 0xF009, &[0i32, 0, 10_000, 10].iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<_>>())]);
|
||||
let members: Vec<Vec<u8>> = (0..1000).map(|i| sp(&[fsp(1, 4000 + i as u32, 0xA02), rect(i * 10)])).collect();
|
||||
let group = container(0xF003, &[vec![head], members].concat());
|
||||
let art = art_content(&[], &[group]);
|
||||
let n = 5000u32;
|
||||
let cps: Vec<u32> = (1..=n + 1).collect();
|
||||
let spas: Vec<u8> = (0..n).flat_map(|_| spa(3000, [0, 0, 1440, 1440], spa_flags(0, 0, 3, false))).collect();
|
||||
let text = "A\u{8}\u{8}\u{8}B\r";
|
||||
let ccp = text.chars().count() as u32;
|
||||
let spec = StructSpec { text, extra_pairs: vec![(40, plc(&cps, &spas)), (50, art)], ..structured_spec(text, ccp) };
|
||||
let t = std::time::Instant::now();
|
||||
let doc = read(&struct_doc(&spec)).expect("opens");
|
||||
// About 10 ms on a loaded machine, so 10 s is a backstop; the old code (a copy per entry,
|
||||
// 6-8 s here) is caught by the group count below, one group per anchor ([1000; 3]).
|
||||
assert!(t.elapsed().as_secs() < 10, "opened in {:?}", t.elapsed());
|
||||
let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para");
|
||||
let groups: Vec<usize> =
|
||||
p0.objects.iter().filter_map(|o| if let InlineObject::Group { children, .. } = o { Some(children.len()) } else { None }).collect();
|
||||
assert_eq!(groups, [1000]);
|
||||
}
|
||||
|
||||
/// #3710: a floating picture's copy is charged its alt text too, and a group every member's.
|
||||
#[test]
|
||||
fn a_floating_copy_is_charged_its_alt_text() {
|
||||
let image = |alt: String| InlineObject::Image {
|
||||
media: String::new(),
|
||||
w: 1.0,
|
||||
h: 1.0,
|
||||
alt,
|
||||
float: Default::default(),
|
||||
crop: Default::default(),
|
||||
ole: None,
|
||||
};
|
||||
let bare = crate::object_cost(&image(String::new()));
|
||||
assert_eq!(crate::object_cost(&image("€".repeat(4096))), bare + 3 * 4096);
|
||||
let member = |obj| wordcraft_doc::para::GroupChild { x: 0.0, y: 0.0, obj };
|
||||
let group = InlineObject::Group {
|
||||
w: 1.0,
|
||||
h: 1.0,
|
||||
float: Default::default(),
|
||||
ch_w: 1.0,
|
||||
ch_h: 1.0,
|
||||
children: vec![member(image("x".repeat(100))), member(image(String::new()))],
|
||||
canvas: None,
|
||||
};
|
||||
assert_eq!(crate::object_cost(&group), 3 * bare + 100);
|
||||
}
|
||||
|
||||
/// A deflated WMF BLIP record declaring `size` uncompressed bytes.
|
||||
fn packed_wmf(size: u32, packed: &[u8]) -> Vec<u8> {
|
||||
let mut hdr = size.to_le_bytes().to_vec();
|
||||
hdr.extend_from_slice(&[0u8; 24]); // rcBounds, ptSize
|
||||
hdr.extend_from_slice(&(packed.len() as u32).to_le_bytes());
|
||||
hdr.extend_from_slice(&[0x00, 0xFE]); // compression = deflate, filter
|
||||
rec(0, 0x216, officeart::rt::BLIP_WMF, &[[0u8; 16].as_slice(), &hdr, packed].concat())
|
||||
}
|
||||
|
||||
/// An `OfficeArtFBSE` body whose BLIP (of `size` bytes) sits at `fo_delay` in the delay stream.
|
||||
fn delayed_fbse(size: usize, fo_delay: usize) -> Vec<u8> {
|
||||
let mut fbse = vec![0u8; 36];
|
||||
fbse[20..24].copy_from_slice(&(size as u32).to_le_bytes());
|
||||
fbse[28..32].copy_from_slice(&(fo_delay as u32).to_le_bytes());
|
||||
fbse
|
||||
}
|
||||
|
||||
/// #3711: store entries aliasing one delay-stream metafile each inflated (and copied) it again.
|
||||
/// Now the record is decoded once and every entry shares its media key.
|
||||
#[test]
|
||||
fn store_entries_aliasing_one_delayed_metafile_decode_it_once() {
|
||||
let wmf = vec![0u8; 1 << 20];
|
||||
let delay = packed_wmf(wmf.len() as u32, &miniz_oxide::deflate::compress_to_vec_zlib(&wmf, 6));
|
||||
let fbse = delayed_fbse(delay.len(), 0);
|
||||
let store: Vec<&[u8]> = vec![&fbse; 5_000];
|
||||
let mut pics = crate::media::Pictures::default();
|
||||
pics.work = officeart::Decode::new(8 << 20);
|
||||
let keys: Vec<_> = (1..=store.len() as u32).map(|pib| pics.bse(pib, &store, &delay)).collect();
|
||||
assert!(keys.iter().all(|k| k.is_some() && *k == keys[0]), "every entry shares one picture");
|
||||
assert_eq!(pics.work.left, (8 << 20) - wmf.len(), "inflated once");
|
||||
}
|
||||
|
||||
/// #3711: distinct metafiles that fail to inflate are charged their declared size anyway, so
|
||||
/// a document of them stops decoding once the work budget is spent.
|
||||
#[test]
|
||||
fn failing_metafile_inflations_spend_the_decode_budget() {
|
||||
let records: Vec<Vec<u8>> = (0..10u8).map(|i| packed_wmf(1 << 20, &[0x78, 0x9C, i, 1, 2, 3])).collect();
|
||||
let delay = records.concat();
|
||||
let mut at = 0;
|
||||
let fbses: Vec<Vec<u8>> = records
|
||||
.iter()
|
||||
.map(|r| {
|
||||
at += r.len();
|
||||
delayed_fbse(r.len(), at - r.len())
|
||||
})
|
||||
.collect();
|
||||
let store: Vec<&[u8]> = fbses.iter().map(Vec::as_slice).collect();
|
||||
let mut pics = crate::media::Pictures::default();
|
||||
pics.work = officeart::Decode::new(3 << 20);
|
||||
for pib in 1..=store.len() as u32 {
|
||||
assert!(pics.bse(pib, &store, &delay).is_none());
|
||||
}
|
||||
assert_eq!(pics.work.left, 0, "three attempts spent the budget; the rest weren't tried");
|
||||
}
|
||||
|
||||
/// #3712: a DIB's declared pixels are charged before decoding (an RLE bitmap is tiny on disk but
|
||||
/// decodes to w × h × 4 bytes); past the budget it stays a BMP placeholder, undecoded.
|
||||
#[test]
|
||||
fn dib_decoding_is_charged_its_declared_pixels() {
|
||||
let blip_of = |dib: &[u8]| rec(0, 0x7A8, officeart::rt::BLIP_DIB, &[[0u8; 16].as_slice(), &[0xFF], dib].concat());
|
||||
let one = blip_of(&one_pixel_dib());
|
||||
let (one, _) = officeart::record(&one).unwrap();
|
||||
let mut work = officeart::Decode::new(3);
|
||||
assert!(officeart::blip(&one, 512, &mut work).is_none(), "not even the copy fits: nothing is copied");
|
||||
assert_eq!(work.left, 3, "nothing decoded, nothing charged");
|
||||
let dib_len = one.body.len() - 17;
|
||||
let mut work = officeart::Decode::new(dib_len);
|
||||
assert_eq!(officeart::blip(&one, 512, &mut work).map(|b| (b.ext, b.placeholder)), Some(("png", false)));
|
||||
assert_eq!(work.left, 0, "the copy is charged, not just 4 bytes a pixel");
|
||||
|
||||
// 8-bit RLE declaring 16384 × 16384 (1 GiB decoded) in a few bytes.
|
||||
let mut big = [40u32, 16_384, 16_384].iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<_>>();
|
||||
big.extend_from_slice(&1u16.to_le_bytes());
|
||||
big.extend_from_slice(&8u16.to_le_bytes());
|
||||
big.extend_from_slice(&1u32.to_le_bytes()); // BI_RLE8
|
||||
big.extend_from_slice(&[0u8; 12]);
|
||||
big.extend_from_slice(&1u32.to_le_bytes()); // one palette colour
|
||||
big.extend_from_slice(&[0u8; 4]); // clrImportant
|
||||
big.extend_from_slice(&[0u8; 4]); // the palette colour
|
||||
big.extend_from_slice(&[0, 0, 0, 1]); // end of line, end of bitmap
|
||||
let big = blip_of(&big);
|
||||
let (big, _) = officeart::record(&big).unwrap();
|
||||
let mut work = officeart::Decode::new(512 << 20);
|
||||
assert_eq!(officeart::blip(&big, 1 << 20, &mut work).map(|b| (b.ext, b.placeholder)), Some(("bmp", true)));
|
||||
assert_eq!(work.left, (512 << 20) - (big.body.len() - 17), "only the copy of the DIB was charged, not its declared pixels");
|
||||
}
|
||||
|
||||
/// An EMF `OfficeArtBlip` record (compressed) with `raw` as its stream, claiming `cb_size`.
|
||||
fn emf_blip(cb_size: u32, raw: &[u8]) -> Vec<u8> {
|
||||
let mut hdr = vec![0u8; 34];
|
||||
hdr[0..4].copy_from_slice(&cb_size.to_le_bytes());
|
||||
hdr[28..32].copy_from_slice(&(raw.len() as u32).to_le_bytes());
|
||||
hdr[32] = 0; // compressed
|
||||
hdr[33] = 0xFE;
|
||||
rec(0, 0x3D4, 0xF01A, &[[0u8; 16].as_slice(), &hdr, raw].concat())
|
||||
}
|
||||
|
||||
/// #3871 review: the zlib input is charged to the decode budget, not just what it inflates to.
|
||||
#[test]
|
||||
fn compressed_metafile_input_is_charged_to_the_decode_budget() {
|
||||
let raw = vec![0x5Au8; 1 << 20]; // not even zlib: costs its input all the same
|
||||
let r = emf_blip(1, &raw);
|
||||
let (emf, _) = officeart::record(&r).unwrap();
|
||||
let mut work = officeart::Decode::new(10 << 20);
|
||||
assert!(officeart::blip(&emf, 64 << 20, &mut work).is_none());
|
||||
assert_eq!(work.left, (10 << 20) - raw.len(), "1 MB of input charged, however little it claims to inflate to");
|
||||
// Ten more of them exhaust the budget; none is inflated past it.
|
||||
let mut work = officeart::Decode::new(3 << 20);
|
||||
for _ in 0..10 {
|
||||
let _ = officeart::blip(&emf, 64 << 20, &mut work);
|
||||
}
|
||||
assert_eq!(work.left, 0, "three attempts fit, the rest are refused");
|
||||
}
|
||||
|
||||
/// An inline `PICFAndOfficeArtData` (shape, FBSE) whose BLIP sits at `fo_delay` in Data; the
|
||||
/// FBSE claims `size` bytes.
|
||||
fn inline_pic(fo_delay: u32, size: u32) -> Vec<u8> {
|
||||
let mut fbse = vec![0u8; 36];
|
||||
fbse[20..24].copy_from_slice(&size.to_le_bytes());
|
||||
fbse[28..32].copy_from_slice(&fo_delay.to_le_bytes());
|
||||
let body = [container(0xF004, &[]), rec(2, 6, 0xF007, &fbse)].concat();
|
||||
let mut picf = vec![0u8; 0x44];
|
||||
picf[0..4].copy_from_slice(&((0x44 + body.len()) as u32).to_le_bytes());
|
||||
picf[4..6].copy_from_slice(&0x44u16.to_le_bytes());
|
||||
picf[6..8].copy_from_slice(&0x64u16.to_le_bytes()); // MM_SHAPE
|
||||
picf[28..30].copy_from_slice(&1440i16.to_le_bytes());
|
||||
picf[30..32].copy_from_slice(&1440i16.to_le_bytes());
|
||||
[picf, body].concat()
|
||||
}
|
||||
|
||||
/// #3871 review: inline pictures that alias one BLIP record share one decode, whatever sizes
|
||||
/// their FBSEs claim and wherever their PICFs sit.
|
||||
#[test]
|
||||
fn inline_pictures_aliasing_one_blip_record_are_decoded_once() {
|
||||
let blip = emf_blip(1, &vec![0x5Au8; 64 << 10]);
|
||||
let a = inline_pic(0, 70_000);
|
||||
let b = inline_pic(0, 80_000);
|
||||
let c = inline_pic(0, 90_000);
|
||||
let blip_at = (a.len() + b.len() + c.len()) as u32;
|
||||
let (a, b, c) = (inline_pic(blip_at, 70_000), inline_pic(blip_at, 80_000), inline_pic(blip_at, 90_000));
|
||||
let data = [a.clone(), b.clone(), c, blip].concat();
|
||||
let mut pics = crate::media::Pictures::default();
|
||||
let start = pics.work.left;
|
||||
let _ = pics.get(&data, 0);
|
||||
let _ = pics.get(&data, a.len() as u32);
|
||||
let _ = pics.get(&data, (a.len() + b.len()) as u32);
|
||||
assert_eq!(start - pics.work.left, 64 << 10, "one record, one charge");
|
||||
}
|
||||
|
||||
/// A 1-bit-per-pixel DIB of `side`×`side` pixels with `bytes` of pixel data.
|
||||
fn dib_1bpp(side: u32, bytes: usize) -> Vec<u8> {
|
||||
let mut d = vec![0u8; 40];
|
||||
d[0..4].copy_from_slice(&40u32.to_le_bytes());
|
||||
d[4..8].copy_from_slice(&side.to_le_bytes());
|
||||
d[8..12].copy_from_slice(&side.to_le_bytes());
|
||||
d[12..14].copy_from_slice(&1u16.to_le_bytes());
|
||||
d[14..16].copy_from_slice(&1u16.to_le_bytes());
|
||||
d.extend_from_slice(&[0, 0, 0, 0, 255, 255, 255, 0]); // palette
|
||||
d.extend(std::iter::repeat_n(0u8, bytes));
|
||||
d
|
||||
}
|
||||
|
||||
fn dib_record(dib: &[u8]) -> Vec<u8> {
|
||||
rec(0, 0x7A8, 0xF01F, &[[0u8; 16].as_slice(), &[0xFF], dib].concat())
|
||||
}
|
||||
|
||||
/// #3871 review: the decode budget scales with the input, so a document of many legitimate
|
||||
/// large scans decodes them all; a high-ratio set is still capped.
|
||||
#[test]
|
||||
fn the_decode_budget_scales_with_the_input_but_still_caps_bombs() {
|
||||
let floor = 1 << 20;
|
||||
// 20 legitimate 1024² 1-bit scans: 4 MB charged each, 128 KB of file each.
|
||||
let real = dib_record(&dib_1bpp(1024, 1024 * 1024 / 8));
|
||||
let (rec_real, _) = officeart::record(&real).unwrap();
|
||||
let input = 20 * real.len();
|
||||
let mut work = officeart::Decode::new(crate::media::decode_work(input, floor));
|
||||
for i in 0..20 {
|
||||
let b = officeart::blip(&rec_real, 64 << 20, &mut work).unwrap();
|
||||
assert!(!b.placeholder, "scan {i} decodes");
|
||||
}
|
||||
// Declared 20000² (1.6 GB each) in a few dozen bytes each: a placeholder, never decoded.
|
||||
let bomb = dib_record(&dib_1bpp(20_000, 16));
|
||||
let (rec_bomb, _) = officeart::record(&bomb).unwrap();
|
||||
let mut work = officeart::Decode::new(crate::media::decode_work(20 * bomb.len(), floor));
|
||||
for _ in 0..20 {
|
||||
assert!(officeart::blip(&rec_bomb, 64 << 20, &mut work).unwrap().placeholder);
|
||||
}
|
||||
assert!(work.left <= crate::media::decode_work(20 * bomb.len(), floor), "nothing was refunded or invented");
|
||||
}
|
||||
|
||||
/// #3871 review: the copy of a DIB into a BMP is charged before it is made, however few pixels
|
||||
/// the header declares; with the budget spent nothing is copied at all.
|
||||
#[test]
|
||||
fn a_dib_with_a_large_tail_is_charged_for_its_copy() {
|
||||
let big = dib_record(&dib_1bpp(1, 1 << 20)); // 1 pixel declared, 1 MB of trailing bytes
|
||||
let (big, _) = officeart::record(&big).unwrap();
|
||||
let dib_len = big.body.len() - 17;
|
||||
let mut work = officeart::Decode::new(10 << 20);
|
||||
assert!(!officeart::blip(&big, 64 << 20, &mut work).unwrap().placeholder);
|
||||
assert_eq!(10 << 20, work.left + dib_len, "charged for the bytes, not just 4 per pixel");
|
||||
// Room for the copy: decoded. Not even that: nothing is copied.
|
||||
let mut work = officeart::Decode::new(dib_len + 1);
|
||||
assert!(officeart::blip(&big, 64 << 20, &mut work).is_some());
|
||||
let mut work = officeart::Decode::new(dib_len - 1);
|
||||
assert!(officeart::blip(&big, 64 << 20, &mut work).is_none());
|
||||
assert_eq!(work.left, dib_len - 1);
|
||||
}
|
||||
|
||||
@@ -199,6 +199,9 @@ fn unsupported_variants_say_so() {
|
||||
let (h, k) = capi_header("pw", 128, hflags);
|
||||
let e = read_with_password(&rc4_file_with(h, &*k), Some("pw")).unwrap_err();
|
||||
assert!(matches!(e, DocbinError::Encryption(_)), "{e:?}");
|
||||
if hflags & 0x20 != 0 {
|
||||
assert!(e.to_string().contains("only defined for the newer encrypted-package format"), "{e}");
|
||||
}
|
||||
}
|
||||
let (mut h, k) = rc4_header("pw");
|
||||
h[0] = 9;
|
||||
|
||||
@@ -31,6 +31,7 @@ zeroize = { workspace = true }
|
||||
getrandom = { workspace = true, features = ["wasm_js"] }
|
||||
|
||||
[dev-dependencies]
|
||||
wordcraft-doc = { workspace = true, features = ["test-support"] }
|
||||
proptest = { workspace = true }
|
||||
image = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! [`spec_of`] turns a chart part back into the model only when writing that model again gives
|
||||
//! the same markup, so a chart with anything the model leaves out stays as it was read.
|
||||
|
||||
use wordcraft_doc::chart::{ChartSeries, ChartSpec, ChartType, LegendPos};
|
||||
use wordcraft_doc::chart::{ChartSeries, ChartSpec, ChartType, LegendPos, MAX_POINTS, MAX_SERIES};
|
||||
use wordcraft_doc::graphic::GraphicItem;
|
||||
use wordcraft_doc::props::Rgb;
|
||||
|
||||
@@ -23,9 +23,21 @@ pub(crate) const CHART_URI: &str = "http://schemas.openxmlformats.org/drawingml/
|
||||
const CAT_AX: &str = "500000001";
|
||||
const VAL_AX: &str = "500000002";
|
||||
|
||||
/// The chart part for `spec`: a complete XML document. `spec` should be sanitized
|
||||
/// ([`ChartSpec::sanitize`]); a value that isn't finite is written as a gap either way.
|
||||
/// The chart part for `spec`: a complete XML document, written as [`ChartSpec::sanitize`] leaves
|
||||
/// it (a value that isn't finite is a gap).
|
||||
pub fn chart_xml(spec: &ChartSpec) -> String {
|
||||
chart_part(spec).0
|
||||
}
|
||||
|
||||
/// [`chart_xml`], and whether the chart limits left series or points of `spec` out of it.
|
||||
pub(crate) fn chart_part(spec: &ChartSpec) -> (String, bool) {
|
||||
let kept = bounded(spec);
|
||||
let points = spec.series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(spec.categories.len());
|
||||
let cut = kept.series.len() < spec.series.len() || kept.points() < points;
|
||||
(write_chart(&kept), cut)
|
||||
}
|
||||
|
||||
fn write_chart(spec: &ChartSpec) -> String {
|
||||
let mut w = W::new();
|
||||
w.open("c:chartSpace", &[("xmlns:c", NS_C), ("xmlns:a", NS_A), ("xmlns:r", NS_R)]);
|
||||
w.empty("c:roundedCorners", &[("val", "0")]);
|
||||
@@ -79,6 +91,26 @@ pub fn chart_xml(spec: &ChartSpec) -> String {
|
||||
w.s
|
||||
}
|
||||
|
||||
/// `spec` held to the chart limits. A model deserialised as is (a `.json` document) never went
|
||||
/// through [`ChartSpec::sanitize`], and every series repeats every category, so its size would
|
||||
/// multiply (#3648). Only what the limits keep is copied before sanitizing.
|
||||
fn bounded(spec: &ChartSpec) -> ChartSpec {
|
||||
ChartSpec {
|
||||
kind: spec.kind,
|
||||
categories: spec.categories.iter().take(MAX_POINTS).cloned().collect(),
|
||||
series: spec
|
||||
.series
|
||||
.iter()
|
||||
.take(MAX_SERIES)
|
||||
.map(|s| ChartSeries { name: s.name.clone(), values: s.values.iter().take(MAX_POINTS).copied().collect() })
|
||||
.collect(),
|
||||
title: spec.title.clone(),
|
||||
legend: spec.legend,
|
||||
data_labels: spec.data_labels,
|
||||
}
|
||||
.sanitized()
|
||||
}
|
||||
|
||||
/// What `spec` draws as, `w` × `h` points, in the colours of `theme` (series take the accents in
|
||||
/// turn). Empty when the chart has no series.
|
||||
pub fn chart_items(spec: &ChartSpec, theme: &[Rgb], w: f32, h: f32) -> Vec<GraphicItem> {
|
||||
@@ -421,6 +453,27 @@ mod tests {
|
||||
e.els().map(|c| c.name.as_str()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chart_past_the_limits_is_written_within_them() {
|
||||
// A model deserialised as is (a `.json` document) never went through `sanitize`, and every
|
||||
// series repeats every category: 300 series of 4,500 points wrote 2.7 million points (#3648).
|
||||
use wordcraft_doc::chart::MAX_CELLS;
|
||||
let spec = ChartSpec {
|
||||
kind: ChartType::Column,
|
||||
categories: (0..4500).map(|i| i.to_string()).collect(),
|
||||
series: (0..300).map(|i| ChartSeries { name: format!("S{i}"), values: vec![Some(1.0); 4500] }).collect(),
|
||||
title: None,
|
||||
legend: None,
|
||||
data_labels: false,
|
||||
};
|
||||
let x = chart_xml(&spec);
|
||||
let series = x.matches("<c:ser>").count();
|
||||
let points = x.matches("<c:pt ").count();
|
||||
assert!((1..=MAX_SERIES).contains(&series), "{series} series");
|
||||
assert!(points <= 2 * MAX_CELLS, "{points} points");
|
||||
assert!(!x.contains(&format!("<c:pt idx=\"{MAX_POINTS}\"")), "past the point limit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_type_reads_back_as_itself() {
|
||||
for kind in ChartType::ALL {
|
||||
|
||||
+369
-2
@@ -9,6 +9,10 @@
|
||||
//! a verifier that tells a wrong password apart; an HMAC over the encrypted stream checks that it
|
||||
//! hasn't been damaged or tampered with.
|
||||
//!
|
||||
//! Older packages (Word 2007) use the *Standard* method (§2.3.4.5–§2.3.4.9): a binary
|
||||
//! `EncryptionInfo`, one AES key derived from the password with SHA-1 and 50,000 spins, and the
|
||||
//! whole package AES-ECB encrypted. [`decrypt`] reads both; [`encrypt`] writes agile only.
|
||||
//!
|
||||
//! Files are hostile: every stream read is capped, every length is checked before slicing, and the
|
||||
//! spin count is bounded by the specification's maximum. Passwords and keys are wiped from memory
|
||||
//! when dropped ([`Zeroizing`]) and never logged.
|
||||
@@ -47,6 +51,20 @@ const BK_KEY_VALUE: [u8; 8] = [0x14, 0x6e, 0x0b, 0xe7, 0xab, 0xac, 0xd0, 0xd6];
|
||||
const BK_HMAC_KEY: [u8; 8] = [0x5f, 0xb2, 0xad, 0x01, 0x0c, 0xb9, 0xe1, 0xf6];
|
||||
const BK_HMAC_VALUE: [u8; 8] = [0xa0, 0x67, 0x7f, 0x02, 0xb2, 0x2c, 0x84, 0x33];
|
||||
|
||||
/// Standard encryption (§2.3.2, §2.3.4.5–§2.3.4.7): `EncryptionHeader.Flags` bits, the AES and
|
||||
/// SHA-1 algorithm ids, the provider type, the fixed spin count and the SHA-1 size.
|
||||
const F_CRYPTO_API: u32 = 0x04;
|
||||
const F_EXTERNAL: u32 = 0x10;
|
||||
const F_AES: u32 = 0x20;
|
||||
const STANDARD_FLAGS: u32 = F_CRYPTO_API | F_AES;
|
||||
const ALG_AES_128: u32 = 0x660E;
|
||||
const ALG_AES_192: u32 = 0x660F;
|
||||
const ALG_AES_256: u32 = 0x6610;
|
||||
const ALG_SHA1: u32 = 0x8004;
|
||||
const PROV_RSA_AES: u32 = 0x18;
|
||||
const STANDARD_SPIN_COUNT: u32 = 50_000;
|
||||
const SHA1_SIZE: usize = 20;
|
||||
|
||||
/// An OLE compound file starts with these bytes ([MS-CFB] §2.2).
|
||||
const CFB_MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
|
||||
|
||||
@@ -56,7 +74,7 @@ const NS_PASSWORD: &str = "http://schemas.microsoft.com/office/2006/keyEncryptor
|
||||
/// Is this an encrypted Office package (a compound file with `EncryptionInfo` and
|
||||
/// `EncryptedPackage` streams) rather than a zip?
|
||||
pub fn is_encrypted(bytes: &[u8]) -> bool {
|
||||
if bytes.get(..8) != Some(&CFB_MAGIC[..]) {
|
||||
if bytes.get(..8) != Some(&CFB_MAGIC[..]) || wordcraft_doc::cfb_guard::check(bytes).is_err() {
|
||||
return false;
|
||||
}
|
||||
match cfb::CompoundFile::open(Cursor::new(bytes)) {
|
||||
@@ -69,9 +87,13 @@ pub fn is_encrypted(bytes: &[u8]) -> bool {
|
||||
/// [`DocxError::PasswordRequired`] once it knows the file is one it can decrypt; a password that
|
||||
/// doesn't fit gives [`DocxError::WrongPassword`].
|
||||
pub fn decrypt(bytes: &[u8], password: Option<&str>) -> Result<Vec<u8>, DocxError> {
|
||||
wordcraft_doc::cfb_guard::check(bytes).map_err(|e| bad(format!("not a readable compound file ({e})")))?;
|
||||
let mut comp = cfb::CompoundFile::open(Cursor::new(bytes)).map_err(|e| bad(format!("not a readable compound file ({e})")))?;
|
||||
let info = stream(&mut comp, "/EncryptionInfo", MAX_INFO)?;
|
||||
let package = stream(&mut comp, "/EncryptedPackage", bytes.len() as u64)?;
|
||||
if matches!(info.get(..4), Some([2..=4, 0, 2, 0])) {
|
||||
return decrypt_standard(&info, &package, password);
|
||||
}
|
||||
let info = parse_info(&info)?;
|
||||
let Some(password) = password else { return Err(DocxError::PasswordRequired) };
|
||||
let pe = &info.password;
|
||||
@@ -125,6 +147,92 @@ pub fn decrypt(bytes: &[u8], password: Option<&str>) -> Result<Vec<u8>, DocxErro
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// [`decrypt`] for Standard encryption (§2.3.4.5–§2.3.4.9): one AES key from the password,
|
||||
/// checked with the verifier, and the whole package AES-ECB encrypted after its 8-byte size.
|
||||
fn decrypt_standard(info: &[u8], package: &[u8], password: Option<&str>) -> Result<Vec<u8>, DocxError> {
|
||||
let s = parse_standard(info)?;
|
||||
let Some(password) = password else { return Err(DocxError::PasswordRequired) };
|
||||
let key = standard_key(s.salt, password, s.key_bytes);
|
||||
// The verifier (§2.3.4.9): a random value and its SHA-1, both encrypted with the key.
|
||||
let verifier = Zeroizing::new(ecb(&key, s.verifier, false)?);
|
||||
let hash = Zeroizing::new(ecb(&key, s.verifier_hash, false)?);
|
||||
let hash = hash.get(..SHA1_SIZE).ok_or_else(|| bad("the password verifier hash is too short"))?;
|
||||
if !same(&Hash::Sha1.hash(&[&verifier]), hash) {
|
||||
return Err(DocxError::WrongPassword);
|
||||
}
|
||||
// The package (§2.3.4.4): StreamSize, then the data; bytes past the last block are ignored.
|
||||
let size = package
|
||||
.get(..8)
|
||||
.and_then(|b| <[u8; 8]>::try_from(b).ok())
|
||||
.map(u64::from_le_bytes)
|
||||
.ok_or_else(|| bad("the encrypted package is truncated"))?;
|
||||
let data = package.get(8..).unwrap_or(&[]);
|
||||
let size = usize::try_from(size).ok().filter(|s| *s <= data.len()).ok_or_else(|| bad("the encrypted package is truncated"))?;
|
||||
let needed = size.div_ceil(BLOCK).saturating_mul(BLOCK);
|
||||
let data = data.get(..needed).ok_or_else(|| bad("the encrypted package is truncated"))?;
|
||||
let mut out = ecb(&key, data, false)?;
|
||||
out.truncate(size);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// What a Standard `EncryptionInfo` stream says (§2.3.4.5): the key size, and the verifier's salt
|
||||
/// and encrypted values.
|
||||
struct Standard<'a> {
|
||||
key_bytes: usize,
|
||||
salt: &'a [u8],
|
||||
verifier: &'a [u8],
|
||||
verifier_hash: &'a [u8],
|
||||
}
|
||||
|
||||
fn parse_standard(info: &[u8]) -> Result<Standard<'_>, DocxError> {
|
||||
let short = || bad("EncryptionInfo is truncated");
|
||||
let u32_at = |at: usize| -> Result<u32, DocxError> {
|
||||
let b = at.checked_add(4).and_then(|end| info.get(at..end)).ok_or_else(short)?;
|
||||
<[u8; 4]>::try_from(b).map(u32::from_le_bytes).map_err(|_| short())
|
||||
};
|
||||
let flags = u32_at(4)?;
|
||||
if flags & F_EXTERNAL != 0 {
|
||||
return Err(bad("this file uses extensible encryption, which WordCraft can't open"));
|
||||
}
|
||||
if flags & STANDARD_FLAGS != STANDARD_FLAGS {
|
||||
return Err(bad("this file's encryption isn't AES, which WordCraft can't open"));
|
||||
}
|
||||
// EncryptionHeader (§2.3.2): Flags, SizeExtra, AlgID, AlgIDHash, KeySize, ProviderType, two
|
||||
// reserved words, then the CSP name, which isn't needed.
|
||||
let header_size = usize::try_from(u32_at(8)?).map_err(|_| short())?;
|
||||
if header_size < 32 {
|
||||
return Err(bad("the encryption header is too short"));
|
||||
}
|
||||
let verifier_at = header_size.checked_add(12).filter(|&v| v <= info.len()).ok_or_else(short)?;
|
||||
// AlgID 0 and AlgIDHash 0 mean "set by the flags": AES-128 and SHA-1.
|
||||
let bits = match u32_at(20)? {
|
||||
0 | ALG_AES_128 => 128,
|
||||
ALG_AES_192 => 192,
|
||||
ALG_AES_256 => 256,
|
||||
id => return Err(bad(format!("encryption algorithm 0x{id:X} isn't supported"))),
|
||||
};
|
||||
let hash_id = u32_at(24)?;
|
||||
if !matches!(hash_id, 0 | ALG_SHA1) {
|
||||
return Err(bad(format!("hash algorithm 0x{hash_id:X} isn't supported")));
|
||||
}
|
||||
if u32_at(28)? != bits {
|
||||
return Err(bad("the key size doesn't match the encryption algorithm"));
|
||||
}
|
||||
// EncryptionVerifier (§2.3.3): SaltSize (16), Salt, EncryptedVerifier, VerifierHashSize (20
|
||||
// for SHA-1), EncryptedVerifierHash (32 bytes for AES).
|
||||
if u32_at(verifier_at)? != SALT as u32 {
|
||||
return Err(bad("the salt isn't 16 bytes"));
|
||||
}
|
||||
let field = |at: usize, len: usize| verifier_at.checked_add(at).and_then(|a| info.get(a..a.checked_add(len)?)).ok_or_else(short);
|
||||
let salt = field(4, SALT)?;
|
||||
let verifier = field(4 + SALT, BLOCK)?;
|
||||
if u32_at(verifier_at.saturating_add(4 + SALT + BLOCK))? != SHA1_SIZE as u32 {
|
||||
return Err(bad("the password verifier hash isn't SHA-1"));
|
||||
}
|
||||
let verifier_hash = field(8 + SALT + BLOCK, 2 * BLOCK)?;
|
||||
Ok(Standard { key_bytes: bits as usize / 8, salt, verifier, verifier_hash })
|
||||
}
|
||||
|
||||
/// Encrypt a zip package with `password` (agile encryption: AES-256-CBC, SHA-512, Word's spin
|
||||
/// count), as Word's "Encrypt with Password" does.
|
||||
pub fn encrypt(package: &[u8], password: &str) -> Result<Vec<u8>, DocxError> {
|
||||
@@ -213,6 +321,47 @@ pub fn encrypt_with_spin_count(package: &[u8], password: &str, spin_count: u32)
|
||||
Ok(comp.into_inner().into_inner())
|
||||
}
|
||||
|
||||
/// A package encrypted with the *Standard* method (§2.3.4.5–§2.3.4.9: AES-ECB, SHA-1, 50,000
|
||||
/// spins), as Word 2007 wrote it, for tests only: WordCraft saves with the agile method
|
||||
/// ([`encrypt`]). `key_bits` is 128, 192 or 256. The compound file holds just the two streams
|
||||
/// [`decrypt`] reads.
|
||||
#[doc(hidden)]
|
||||
pub fn encrypt_standard_for_tests(package: &[u8], password: &str, key_bits: u32) -> Result<Vec<u8>, DocxError> {
|
||||
check_password(password)?;
|
||||
let alg_id = match key_bits {
|
||||
128 => ALG_AES_128,
|
||||
192 => ALG_AES_192,
|
||||
256 => ALG_AES_256,
|
||||
_ => return Err(bad(format!("unsupported AES key size {key_bits}"))),
|
||||
};
|
||||
let salt = random(SALT)?;
|
||||
let verifier = random(SALT)?;
|
||||
let key = standard_key(&salt, password, key_bits as usize / 8);
|
||||
let mut header = Vec::new();
|
||||
for v in [STANDARD_FLAGS, 0, alg_id, ALG_SHA1, key_bits, PROV_RSA_AES, 0, 0] {
|
||||
header.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
// CSPName: an empty null-terminated UTF-16 string.
|
||||
header.extend_from_slice(&[0, 0]);
|
||||
let mut info = vec![3, 0, 2, 0];
|
||||
info.extend_from_slice(&STANDARD_FLAGS.to_le_bytes());
|
||||
info.extend_from_slice(&(header.len() as u32).to_le_bytes());
|
||||
info.extend_from_slice(&header);
|
||||
info.extend_from_slice(&(SALT as u32).to_le_bytes());
|
||||
info.extend_from_slice(&salt);
|
||||
info.extend_from_slice(&ecb(&key, &verifier, true)?);
|
||||
info.extend_from_slice(&(SHA1_SIZE as u32).to_le_bytes());
|
||||
info.extend_from_slice(&ecb(&key, &padded(&Hash::Sha1.hash(&[&verifier])), true)?);
|
||||
let mut stream = (package.len() as u64).to_le_bytes().to_vec();
|
||||
stream.extend_from_slice(&ecb(&key, &padded(package), true)?);
|
||||
let io = |e: std::io::Error| bad(format!("couldn't write the compound file ({e})"));
|
||||
let mut comp = cfb::CompoundFile::create_with_version(cfb::Version::V3, Cursor::new(Vec::new())).map_err(io)?;
|
||||
comp.create_stream("/EncryptionInfo").map_err(io)?.write_all(&info).map_err(io)?;
|
||||
comp.create_stream("/EncryptedPackage").map_err(io)?.write_all(&stream).map_err(io)?;
|
||||
comp.flush().map_err(io)?;
|
||||
Ok(comp.into_inner().into_inner())
|
||||
}
|
||||
|
||||
/// Refuse a password Word wouldn't accept: empty, or longer than [`MAX_PASSWORD_CHARS`].
|
||||
pub fn check_password(password: &str) -> Result<(), DocxError> {
|
||||
if password.is_empty() {
|
||||
@@ -497,6 +646,45 @@ fn derive_key(hash: Hash, hn: &[u8], block_key: &[u8], len: usize) -> Zeroizing<
|
||||
k
|
||||
}
|
||||
|
||||
/// Standard encryption's key (§2.3.4.7): the SHA-1 password hash after 50,000 iterations, hashed
|
||||
/// with block number 0; then X1 = SHA-1 of that hash XOR-ed into 64 bytes of 0x36, X2 the same
|
||||
/// with 0x5C, and the key is the first `len` bytes of X1 + X2.
|
||||
fn standard_key(salt: &[u8], password: &str, len: usize) -> Zeroizing<Vec<u8>> {
|
||||
let hn = iterate(Hash::Sha1, salt, password, STANDARD_SPIN_COUNT);
|
||||
let hfinal = Zeroizing::new(Hash::Sha1.hash(&[&hn, &0u32.to_le_bytes()]));
|
||||
let x = |fill: u8| {
|
||||
let mut buf = Zeroizing::new([fill; 64]);
|
||||
for (b, h) in buf.iter_mut().zip(hfinal.iter()) {
|
||||
*b ^= h;
|
||||
}
|
||||
Zeroizing::new(Hash::Sha1.hash(&[&buf[..]]))
|
||||
};
|
||||
let mut key = Zeroizing::new(x(0x36).to_vec());
|
||||
key.extend_from_slice(&x(0x5C));
|
||||
key.truncate(len);
|
||||
key
|
||||
}
|
||||
|
||||
/// AES-ECB without padding (Standard encryption, §2.3.4.7–§2.3.4.9): `data` must be whole blocks.
|
||||
fn ecb(key: &[u8], data: &[u8], encrypt: bool) -> Result<Vec<u8>, DocxError> {
|
||||
let aes = Aes::new(key)?;
|
||||
let (blocks, rest) = data.as_chunks::<BLOCK>();
|
||||
if !rest.is_empty() {
|
||||
return Err(bad("encrypted data isn't a whole number of blocks"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(data.len());
|
||||
for b in blocks {
|
||||
let mut x = aes::Block::from(*b);
|
||||
if encrypt {
|
||||
aes.encrypt(&mut x);
|
||||
} else {
|
||||
aes.decrypt(&mut x);
|
||||
}
|
||||
out.extend_from_slice(&x);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// An initialization vector (§2.3.4.12): H(salt + blockKey), or the salt itself, truncated to
|
||||
/// the block size or padded with 0x36.
|
||||
fn make_iv(hash: Hash, salt: &[u8], block_key: Option<&[u8]>, block_size: usize) -> Vec<u8> {
|
||||
@@ -643,7 +831,6 @@ fn parse_info(info: &[u8]) -> Result<Info, DocxError> {
|
||||
};
|
||||
match (major, minor) {
|
||||
(4, 4) => {}
|
||||
(2..=4, 2) => return Err(bad("this file uses Standard encryption (Office 2007), which WordCraft can't open yet")),
|
||||
(3 | 4, 3) => return Err(bad("this file uses extensible encryption, which WordCraft can't open")),
|
||||
_ => return Err(bad(format!("unknown encryption version {major}.{minor}"))),
|
||||
}
|
||||
@@ -992,4 +1179,184 @@ mod tests {
|
||||
assert!(!is_encrypted(&fake));
|
||||
assert!(decrypt(&fake, Some("pw")).is_err());
|
||||
}
|
||||
|
||||
/// Standard encryption's key derivation (§2.3.4.7) for the password "Password1234_" and the
|
||||
/// salt 00 01 … 0F. The expected keys were computed outside WordCraft (Python's hashlib)
|
||||
/// following the specification's steps, so the encryptor and decryptor can't share a mistake.
|
||||
#[test]
|
||||
fn standard_key_matches_an_independent_derivation() {
|
||||
let salt: Vec<u8> = (0..16).collect();
|
||||
let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
|
||||
assert_eq!(hex(&standard_key(&salt, "Password1234_", 16)), "340fc93a8e2154db8304d055df81cebc");
|
||||
assert_eq!(hex(&standard_key(&salt, "Password1234_", 32)), "340fc93a8e2154db8304d055df81cebcef5e89fb8e5eb520efdca76f3330e032");
|
||||
assert_eq!(standard_key(&salt, "Password1234_", 24).len(), 24);
|
||||
// AES-ECB against FIPS-197 Appendix C.1 and C.3.
|
||||
let pt: Vec<u8> = (0..16u8).map(|i| i * 0x11).collect();
|
||||
let k128: Vec<u8> = (0..16).collect();
|
||||
let k256: Vec<u8> = (0..32).collect();
|
||||
assert_eq!(hex(&ecb(&k128, &pt, true).unwrap()), "69c4e0d86a7b0430d8cdb78070b4c55a");
|
||||
assert_eq!(hex(&ecb(&k256, &pt, true).unwrap()), "8ea2b7ca516745bfeafc49904b496089");
|
||||
assert_eq!(ecb(&k256, &ecb(&k256, &pt, true).unwrap(), false).unwrap(), pt);
|
||||
assert!(ecb(&k128, &pt[..15], false).is_err());
|
||||
}
|
||||
|
||||
/// Standard encryption (Word 2007's AES-ECB method) opens with the right password, for each
|
||||
/// AES key size, and says when the password is missing or wrong — as agile files do.
|
||||
#[test]
|
||||
fn standard_encryption_round_trips() {
|
||||
let pkg = zip_package();
|
||||
for bits in [128, 192, 256] {
|
||||
let enc = encrypt_standard_for_tests(&pkg, "pässwörd 🔑", bits).unwrap();
|
||||
assert!(is_encrypted(&enc));
|
||||
assert_eq!(decrypt(&enc, Some("pässwörd 🔑")), Ok(pkg.clone()), "AES-{bits}");
|
||||
assert_eq!(decrypt(&enc, None), Err(DocxError::PasswordRequired));
|
||||
assert_eq!(decrypt(&enc, Some("Pässwörd 🔑")), Err(DocxError::WrongPassword));
|
||||
assert_eq!(crate::read(&enc), Err(DocxError::PasswordRequired));
|
||||
}
|
||||
let enc = encrypt_standard_for_tests(&pkg, "pw", 128).unwrap();
|
||||
let doc = crate::read_with_password(&enc, Some("pw")).unwrap();
|
||||
assert!(doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Top secret plans"));
|
||||
// Sizes on and off the block boundary, and empty.
|
||||
for n in [0usize, 1, 15, 16, 17, SEGMENT + 3] {
|
||||
let data: Vec<u8> = (0..n).map(|i| (i * 7) as u8).collect();
|
||||
assert_eq!(decrypt(&encrypt_standard_for_tests(&data, "x", 256).unwrap(), Some("x")).unwrap(), data);
|
||||
}
|
||||
assert!(encrypt_standard_for_tests(&pkg, "pw", 64).is_err());
|
||||
assert!(encrypt_standard_for_tests(&pkg, "", 128).is_err());
|
||||
}
|
||||
|
||||
/// A Standard-encrypted file assembled byte by byte from [MS-OFFCRYPTO] §2.3.4.5, §2.3.2, §2.3.3
|
||||
/// and §2.3.4.4 with literal field values, without the test encryptor, so a layout mistake
|
||||
/// shared by the encryptor and the reader can't pass. Only the key derivation and AES-ECB
|
||||
/// (pinned by `standard_key_matches_an_independent_derivation`) are reused.
|
||||
fn hand_built_standard(payload: &[u8], password: &str) -> Vec<u8> {
|
||||
let salt: [u8; 16] = [0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD, 0xAE, 0xAF];
|
||||
let verifier: [u8; 16] = *b"verifier-16bytes";
|
||||
let key = standard_key(&salt, password, 16);
|
||||
let mut hash = sha1::Sha1::digest(verifier).to_vec();
|
||||
hash.resize(32, 0); // the hash, zero-padded to two AES blocks
|
||||
let csp: Vec<u8> = "WordCraft test provider".encode_utf16().chain([0]).flat_map(u16::to_le_bytes).collect();
|
||||
|
||||
let mut header = Vec::new();
|
||||
header.extend_from_slice(&0x24u32.to_le_bytes()); // Flags: fCryptoAPI | fAES
|
||||
header.extend_from_slice(&0u32.to_le_bytes()); // SizeExtra
|
||||
header.extend_from_slice(&0x660Eu32.to_le_bytes()); // AlgID: AES-128
|
||||
header.extend_from_slice(&0x8004u32.to_le_bytes()); // AlgIDHash: SHA-1
|
||||
header.extend_from_slice(&128u32.to_le_bytes()); // KeySize, in bits
|
||||
header.extend_from_slice(&0x18u32.to_le_bytes()); // ProviderType: AES
|
||||
header.extend_from_slice(&0u32.to_le_bytes()); // Reserved1
|
||||
header.extend_from_slice(&0u32.to_le_bytes()); // Reserved2
|
||||
header.extend_from_slice(&csp); // CSPName: UTF-16LE, NUL-terminated
|
||||
|
||||
let mut info = Vec::new();
|
||||
info.extend_from_slice(&3u16.to_le_bytes()); // Version major
|
||||
info.extend_from_slice(&2u16.to_le_bytes()); // Version minor
|
||||
info.extend_from_slice(&0x24u32.to_le_bytes()); // Flags: fCryptoAPI | fAES
|
||||
info.extend_from_slice(&(header.len() as u32).to_le_bytes()); // HeaderSize
|
||||
info.extend_from_slice(&header);
|
||||
info.extend_from_slice(&16u32.to_le_bytes()); // SaltSize
|
||||
info.extend_from_slice(&salt); // Salt
|
||||
info.extend_from_slice(&ecb(&key, &verifier, true).unwrap()); // EncryptedVerifier
|
||||
info.extend_from_slice(&20u32.to_le_bytes()); // VerifierHashSize (SHA-1)
|
||||
info.extend_from_slice(&ecb(&key, &hash, true).unwrap()); // EncryptedVerifierHash
|
||||
|
||||
let mut padded = payload.to_vec();
|
||||
padded.resize(payload.len().next_multiple_of(16), 0);
|
||||
let mut package = (payload.len() as u64).to_le_bytes().to_vec(); // StreamSize
|
||||
package.extend_from_slice(&ecb(&key, &padded, true).unwrap());
|
||||
package.extend_from_slice(&[0xEE, 0xEE, 0xEE]); // trailing padding the spec allows
|
||||
cfb_with(&info, &package)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_package_built_by_hand_from_the_spec_opens() {
|
||||
let payload: Vec<u8> = (0..37u8).map(|i| i.wrapping_mul(5)).collect();
|
||||
let file = hand_built_standard(&payload, "pw");
|
||||
assert!(is_encrypted(&file));
|
||||
assert_eq!(decrypt(&file, Some("pw")), Ok(payload.clone()));
|
||||
assert_eq!(decrypt(&file, Some("PW")), Err(DocxError::WrongPassword));
|
||||
assert_eq!(decrypt(&file, None), Err(DocxError::PasswordRequired));
|
||||
// The test encryptor writes the same fixed fields: version and flags (bytes 0..8) and the
|
||||
// header's eight words (12..44); HeaderSize differs with the CSP name, the rest is random.
|
||||
let (hand, _) = streams(&file);
|
||||
let (made, _) = streams(&encrypt_standard_for_tests(&payload, "pw", 128).unwrap());
|
||||
assert_eq!(hand[..8], made[..8]);
|
||||
assert_eq!(hand[12..44], made[12..44]);
|
||||
}
|
||||
|
||||
/// The two streams of an encrypted package.
|
||||
fn streams(file: &[u8]) -> (Vec<u8>, Vec<u8>) {
|
||||
let mut comp = cfb::CompoundFile::open(Cursor::new(file)).unwrap();
|
||||
let mut info = Vec::new();
|
||||
comp.open_stream("/EncryptionInfo").unwrap().read_to_end(&mut info).unwrap();
|
||||
let mut package = Vec::new();
|
||||
comp.open_stream("/EncryptedPackage").unwrap().read_to_end(&mut package).unwrap();
|
||||
(info, package)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_standard_encryption_info_errors_without_panicking() {
|
||||
let data = b"a small package".to_vec();
|
||||
let (info, package) = streams(&encrypt_standard_for_tests(&data, "pw", 128).unwrap());
|
||||
// Layout: version 0, flags 4, header size 8, header 12 (flags, sizeExtra, algId 20,
|
||||
// algIdHash 24, keySize 28, provider, 2 reserved, an empty CSP name), then the verifier at
|
||||
// 46: saltSize, salt 50, encrypted verifier 66, verifierHashSize 82, its hash 86..118.
|
||||
assert_eq!(info.len(), 118);
|
||||
let put = |at: usize, v: u32| {
|
||||
let mut i = info.clone();
|
||||
i[at..at + 4].copy_from_slice(&v.to_le_bytes());
|
||||
i
|
||||
};
|
||||
let mut cases: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
|
||||
for n in 0..info.len() {
|
||||
cases.push((info[..n].to_vec(), package.clone()));
|
||||
}
|
||||
for v in [0, 4, 31, 0x1000, u32::MAX] {
|
||||
cases.push((put(8, v), package.clone()));
|
||||
}
|
||||
// No AES, no CryptoAPI, an external (extensible) provider.
|
||||
for v in [F_CRYPTO_API, F_AES, STANDARD_FLAGS | F_EXTERNAL, 0] {
|
||||
cases.push((put(4, v), package.clone()));
|
||||
}
|
||||
// RC4, MD5, key sizes that don't fit the algorithm.
|
||||
cases.push((put(20, 0x6801), package.clone()));
|
||||
cases.push((put(24, 0x8003), package.clone()));
|
||||
for v in [0, 64, 192, 256, u32::MAX] {
|
||||
cases.push((put(28, v), package.clone()));
|
||||
}
|
||||
for v in [0, 15, 17, u32::MAX] {
|
||||
cases.push((put(46, v), package.clone()));
|
||||
}
|
||||
for v in [0, 16, 32, u32::MAX] {
|
||||
cases.push((put(82, v), package.clone()));
|
||||
}
|
||||
// The package: truncated, an absurd StreamSize, a StreamSize past the data, data that
|
||||
// isn't whole blocks.
|
||||
for n in [0, 4, 8, 9, package.len() - 1] {
|
||||
cases.push((info.clone(), package[..n].to_vec()));
|
||||
}
|
||||
for size in [u64::MAX, 1 << 40, package.len() as u64] {
|
||||
let mut p = package.clone();
|
||||
p[..8].copy_from_slice(&size.to_le_bytes());
|
||||
cases.push((info.clone(), p));
|
||||
}
|
||||
for (i, p) in cases {
|
||||
let file = cfb_with(&i, &p);
|
||||
assert!(decrypt(&file, Some("pw")).is_err());
|
||||
let _ = crate::read_with_password(&file, Some("pw"));
|
||||
assert!(crate::read(&file).is_err());
|
||||
}
|
||||
// Version 2.2 and 4.2 are Standard too; trailing bytes after the last block are ignored.
|
||||
for v in [2u8, 4] {
|
||||
let mut i = info.clone();
|
||||
i[0] = v;
|
||||
assert_eq!(decrypt(&cfb_with(&i, &package), Some("pw")).unwrap(), data);
|
||||
}
|
||||
let mut p = package.clone();
|
||||
p.extend_from_slice(&[1, 2, 3]);
|
||||
assert_eq!(decrypt(&cfb_with(&info, &p), Some("pw")).unwrap(), data);
|
||||
// AlgID and AlgIDHash 0 mean "from the flags": AES-128 and SHA-1.
|
||||
assert_eq!(decrypt(&cfb_with(&put(20, 0), &package), Some("pw")).unwrap(), data);
|
||||
assert_eq!(decrypt(&cfb_with(&put(24, 0), &package), Some("pw")).unwrap(), data);
|
||||
}
|
||||
}
|
||||
|
||||
+99
-1
@@ -437,7 +437,47 @@ fn xml_fragment<'a>(ct: &str, bytes: &'a [u8]) -> Option<&'a str> {
|
||||
Some(rest) => rest.split_once("?>").map(|(_, r)| r)?.trim_start(),
|
||||
None => s,
|
||||
};
|
||||
(s.starts_with('<') && !s.contains("<!DOCTYPE")).then_some(s)
|
||||
(s.starts_with('<') && !s.contains("<!DOCTYPE") && one_element(s)).then_some(s)
|
||||
}
|
||||
|
||||
/// Whether `s` is exactly one well-formed element, with only whitespace, comments and processing
|
||||
/// instructions around it. Kept bytes are inlined into `pkg:xmlData` only then; anything else could
|
||||
/// close the wrapper and add parts of its own, so it goes out as `pkg:binaryData` (#3742).
|
||||
fn one_element(s: &str) -> bool {
|
||||
let mut r = quick_xml::Reader::from_str(s);
|
||||
r.config_mut().check_comments = true;
|
||||
let (mut depth, mut roots) = (0usize, 0usize);
|
||||
loop {
|
||||
let attrs_ok = |e: &BytesStart| e.attributes().all(|a| a.is_ok());
|
||||
match r.read_event() {
|
||||
Ok(Event::Start(e)) => {
|
||||
if !attrs_ok(&e) || (depth == 0 && roots > 0) {
|
||||
return false;
|
||||
}
|
||||
roots += usize::from(depth == 0);
|
||||
depth += 1;
|
||||
}
|
||||
Ok(Event::Empty(e)) => {
|
||||
if !attrs_ok(&e) || (depth == 0 && roots > 0) {
|
||||
return false;
|
||||
}
|
||||
roots += usize::from(depth == 0);
|
||||
}
|
||||
Ok(Event::End(_)) => match depth.checked_sub(1) {
|
||||
Some(d) => depth = d,
|
||||
None => return false,
|
||||
},
|
||||
Ok(Event::Text(t)) if depth == 0 => {
|
||||
if !t.iter().all(u8::is_ascii_whitespace) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Ok(Event::CData(_) | Event::GeneralRef(_)) if depth == 0 => return false,
|
||||
Ok(Event::Decl(_) | Event::DocType(_)) | Err(_) => return false,
|
||||
Ok(Event::Eof) => return depth == 0 && roots == 1,
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -494,6 +534,64 @@ mod tests {
|
||||
assert!(matches!(unpack(package(xml::MAX_DEPTH + 1).as_bytes()), Err(DocxError::Limit(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_xml_that_would_break_out_of_its_part_is_written_in_base64() {
|
||||
// A part kept from a file is written back as it was read; inlined as is, markup that
|
||||
// closes the wrapper adds parts of its own (here a second core properties part, read
|
||||
// before the real one) (#3742).
|
||||
let evil = format!(
|
||||
"<a/></pkg:xmlData></pkg:part><pkg:part pkg:name=\"/docProps/core.xml\" pkg:contentType=\"application/vnd.openxmlformats-package.core-properties+xml\"><pkg:xmlData><cp:coreProperties xmlns:cp=\"{}\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\"><dc:title>Injected</dc:title></cp:coreProperties></pkg:xmlData></pkg:part><pkg:part pkg:name=\"/x.xml\"><pkg:xmlData><b>",
|
||||
"http://schemas.openxmlformats.org/package/2006/metadata/core-properties"
|
||||
);
|
||||
let w_ns = "xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"";
|
||||
let rel = |id: &str, kind: &str, target: &str| format!("<Relationship Id=\"{id}\" Type=\"{kind}\" Target=\"{target}\"/>");
|
||||
let rels = format!(
|
||||
"<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">{}{}</Relationships>",
|
||||
rel("rId1", crate::package::rt::OFFICE_DOC, "word/document.xml"),
|
||||
rel("rId2", crate::package::rt::CUSTOM_XML, "customXml/item1.xml")
|
||||
);
|
||||
let types = "<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"><Default Extension=\"xml\" ContentType=\"application/xml\"/></Types>";
|
||||
let z = crate::package::zip_entries(&[
|
||||
("[Content_Types].xml".into(), types.as_bytes().to_vec()),
|
||||
("_rels/.rels".into(), rels.into_bytes()),
|
||||
("word/document.xml".into(), format!("<w:document {w_ns}><w:body><w:p/></w:body></w:document>").into_bytes()),
|
||||
("customXml/item1.xml".into(), evil.clone().into_bytes()),
|
||||
])
|
||||
.unwrap();
|
||||
let mut doc = crate::read(&z).unwrap();
|
||||
assert!(doc.passthrough.values().any(|b| b.as_slice() == evil.as_bytes()), "the part is kept as read");
|
||||
doc.core.title = "Real".into();
|
||||
let flat = write(&doc).unwrap();
|
||||
let text = String::from_utf8_lossy(&flat);
|
||||
assert_eq!(text.matches("pkg:name=\"/docProps/core.xml\"").count(), 1, "{text}");
|
||||
let back = read(&flat).unwrap();
|
||||
assert_eq!(back.core.title, "Real");
|
||||
assert!(back.passthrough.values().any(|b| b.as_slice() == evil.as_bytes()), "the part survives, in base64");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_one_well_formed_element_is_inlined() {
|
||||
let ct = "application/xml";
|
||||
for ok in ["<a/>", "<?xml version=\"1.0\"?>\n<a x=\"1\"><b>t & <![CDATA[c]]></b></a>\n<!-- end -->", "<!-- c --><a/><?pi x?>"] {
|
||||
assert!(xml_fragment(ct, ok.as_bytes()).is_some(), "{ok}");
|
||||
}
|
||||
for bad in [
|
||||
"<a/><b/>",
|
||||
"<a></b>",
|
||||
"<a>",
|
||||
"<a/></x>",
|
||||
"<a/>text",
|
||||
"<a/>&",
|
||||
"<a x=1/>",
|
||||
"<a/><?xml version=\"1.0\"?>",
|
||||
"<a></a></pkg:xmlData>",
|
||||
"<a><!-- x -- y --></a>",
|
||||
"<a/><!-- x--->",
|
||||
] {
|
||||
assert!(xml_fragment(ct, bad.as_bytes()).is_none(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_and_nameless_parts_count_against_the_size_limits() {
|
||||
let (mut files, mut types, mut total) = (BTreeMap::new(), Vec::new(), 0u64);
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
//! back. [`embedded_fonts`] hands their font files to whoever lays the document out, undoing the
|
||||
//! obfuscation Word applies to them (ECMA-376 Part 1, 17.8.1).
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use wordcraft_doc::Document;
|
||||
|
||||
use crate::package::{EMBEDDED_PARTS, EmbeddedManifest, FONT_TABLE_PART};
|
||||
@@ -11,6 +13,11 @@ use crate::package::{EMBEDDED_PARTS, EmbeddedManifest, FONT_TABLE_PART};
|
||||
/// Most font files taken from one document.
|
||||
const MAX_FONTS: usize = 64;
|
||||
|
||||
/// Most bytes of font files copied out of one document: one part's limit, however many
|
||||
/// references the font table makes (each reference is a copy; 64 of them to one large part
|
||||
/// were 64 copies, #3692).
|
||||
const MAX_FONT_BYTES: u64 = crate::package::MAX_PART;
|
||||
|
||||
/// The font table's references to embedded font files, one per style.
|
||||
const EMBEDS: [&str; 4] = ["w:embedRegular", "w:embedBold", "w:embedItalic", "w:embedBoldItalic"];
|
||||
|
||||
@@ -28,6 +35,12 @@ pub struct EmbeddedFont {
|
||||
/// fonts: that is up to the font database. Empty for a document read from anything but a package
|
||||
/// with a font table.
|
||||
pub fn embedded_fonts(doc: &Document) -> Vec<EmbeddedFont> {
|
||||
fonts_within(doc, MAX_FONT_BYTES)
|
||||
}
|
||||
|
||||
/// [`embedded_fonts`], copying at most `budget` bytes of font files.
|
||||
fn fonts_within(doc: &Document, mut budget: u64) -> Vec<EmbeddedFont> {
|
||||
let budget_total = budget;
|
||||
let Some(table) = doc.passthrough.get(FONT_TABLE_PART).and_then(|b| crate::xml::parse(b).ok()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
@@ -36,6 +49,9 @@ pub fn embedded_fonts(doc: &Document) -> Vec<EmbeddedFont> {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
// A reference repeating one already taken (same file, key and name) gives the same font.
|
||||
let mut taken: HashSet<(&str, [u8; 16], &str)> = HashSet::new();
|
||||
let mut over_budget = 0usize;
|
||||
let embeds = table
|
||||
.children("w:font")
|
||||
.flat_map(|f| f.els().filter(|e| EMBEDS.contains(&e.name.as_str())).map(move |e| (f.attr("w:name").unwrap_or(""), e)));
|
||||
@@ -61,12 +77,25 @@ pub fn embedded_fonts(doc: &Document) -> Vec<EmbeddedFont> {
|
||||
},
|
||||
None => [0; 16],
|
||||
};
|
||||
if !taken.insert((rel.target.as_str(), key, name)) {
|
||||
continue;
|
||||
}
|
||||
// Checked before the copy is made.
|
||||
let Some(left) = budget.checked_sub(bytes.len() as u64) else {
|
||||
over_budget += 1;
|
||||
continue;
|
||||
};
|
||||
budget = left;
|
||||
let mut data = bytes.to_vec();
|
||||
for (i, b) in data.iter_mut().take(32).enumerate() {
|
||||
*b ^= key.get(i % 16).copied().unwrap_or(0);
|
||||
}
|
||||
out.push(EmbeddedFont { name: name.to_string(), data });
|
||||
}
|
||||
if over_budget > 0 {
|
||||
// Once for the document, not once per font.
|
||||
log::warn!("docx: embedded fonts past the {budget_total} byte budget; {over_budget} of them are not loaded");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -83,3 +112,53 @@ fn font_key(guid: &str) -> Option<[u8; 16]> {
|
||||
}
|
||||
Some(key)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A document whose font table makes `refs` references (`(font name, part)`) to font parts
|
||||
/// `parts` (`(name, size)`).
|
||||
fn doc_with(refs: &[(&str, &str)], parts: &[(&str, usize)]) -> Document {
|
||||
let mut table = String::from(
|
||||
r#"<w:fonts xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">"#,
|
||||
);
|
||||
let mut manifest = format!("P\t{FONT_TABLE_PART}\tapplication/xml\n");
|
||||
for (i, (font, part)) in refs.iter().enumerate() {
|
||||
table.push_str(&format!(r#"<w:font w:name="{font}"><w:embedRegular r:id="rId{i}"/></w:font>"#));
|
||||
manifest.push_str(&format!("R\t{FONT_TABLE_PART}\trId{i}\tfont\t{part}\tI\n"));
|
||||
}
|
||||
table.push_str("</w:fonts>");
|
||||
let mut doc = Document::default();
|
||||
doc.passthrough.insert(FONT_TABLE_PART.into(), Arc::new(table.into_bytes()));
|
||||
doc.passthrough.insert(EMBEDDED_PARTS.into(), Arc::new(manifest.into_bytes()));
|
||||
for (part, size) in parts {
|
||||
doc.passthrough.insert((*part).into(), Arc::new(vec![7u8; *size]));
|
||||
}
|
||||
doc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_font_referenced_again_is_copied_once() {
|
||||
// Each reference was its own copy: the 64 references allowed to one 256 MB part were
|
||||
// 16 GB of copies (#3692).
|
||||
let refs = vec![("Body", "word/fonts/a.odttf"); MAX_FONTS];
|
||||
let fonts = embedded_fonts(&doc_with(&refs, &[("word/fonts/a.odttf", 1000)]));
|
||||
assert_eq!(fonts.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_copies_stop_at_the_byte_budget() {
|
||||
let refs: Vec<(String, &str)> = (0..MAX_FONTS).map(|i| (format!("Font {i}"), "word/fonts/a.odttf")).collect();
|
||||
let refs: Vec<(&str, &str)> = refs.iter().map(|(n, p)| (n.as_str(), *p)).collect();
|
||||
let doc = doc_with(&refs, &[("word/fonts/a.odttf", 1000)]);
|
||||
let (fonts, warned) = crate::test_log::warnings(|| fonts_within(&doc, 3500));
|
||||
assert_eq!(fonts.len(), 3, "three copies fit");
|
||||
let budget: Vec<_> = warned.iter().filter(|w| w.contains("byte budget")).collect();
|
||||
assert_eq!(budget.len(), 1, "one warning for the document, not one per font: {warned:?}");
|
||||
assert!(budget.iter().all(|w| w.contains(&format!("{} of them", MAX_FONTS - 3))), "{budget:?}");
|
||||
assert_eq!(embedded_fonts(&doc).len(), MAX_FONTS, "well within the real budget");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ mod fonts;
|
||||
mod package;
|
||||
mod read;
|
||||
mod smart_art;
|
||||
#[cfg(test)]
|
||||
mod test_log;
|
||||
mod units;
|
||||
mod wordml2003;
|
||||
mod wordml2003_write;
|
||||
@@ -25,8 +27,8 @@ mod xml;
|
||||
pub use bibliography::{MAX_SOURCES, Sources, read_sources};
|
||||
pub use chart_spec::{chart_items, chart_xml};
|
||||
pub use crypt::{
|
||||
DEFAULT_SPIN_COUNT, MAX_PASSWORD_CHARS, PasswordCheck, check_password, check_protection_password, decrypt, encrypt, encrypt_with_spin_count,
|
||||
is_encrypted, protection_hash,
|
||||
DEFAULT_SPIN_COUNT, MAX_PASSWORD_CHARS, PasswordCheck, check_password, check_protection_password, decrypt, encrypt, encrypt_standard_for_tests,
|
||||
encrypt_with_spin_count, is_encrypted, protection_hash,
|
||||
};
|
||||
pub use flat::{XmlKind, sniff_xml, write as write_flat};
|
||||
pub use fonts::{EmbeddedFont, embedded_fonts};
|
||||
|
||||
+127
-13
@@ -1,6 +1,7 @@
|
||||
//! OPC package access: bounded zip reading, relationships, part names.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::hash_map::Entry;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -191,6 +192,9 @@ pub fn rel_is(t: &str, full: &str) -> bool {
|
||||
/// picture, an embedded object) is handed over rather than copied.
|
||||
pub struct Package {
|
||||
files: BTreeMap<String, Arc<Vec<u8>>>,
|
||||
/// Lowercased part name → its stored spelling (the first in name order): part names are
|
||||
/// case-insensitive (ECMA-376 Part 2 §6.2.2.3), so every spelling is one part.
|
||||
lower: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Package {
|
||||
@@ -223,24 +227,60 @@ impl Package {
|
||||
let room = max_part.min(max_total.saturating_sub(total));
|
||||
let mut buf = Vec::with_capacity(f.size().min(16 * 1024 * 1024) as usize);
|
||||
let read = (&mut f).take(room + 1).read_to_end(&mut buf);
|
||||
// Charged whether or not the read succeeds: an entry inflates in full before its CRC
|
||||
// is checked, so failed entries would otherwise inflate without limit (#3723).
|
||||
total = total.saturating_add(buf.len() as u64);
|
||||
if buf.len() as u64 > room {
|
||||
return Err(DocxError::Limit(format!("part {name} decompresses past the size limit")));
|
||||
}
|
||||
if let Err(e) = read {
|
||||
// A corrupt entry: keep going unless it's a part we can't do without.
|
||||
log::warn!("docx: zip entry {name} unreadable: {e}");
|
||||
continue;
|
||||
}
|
||||
if buf.len() as u64 > room {
|
||||
return Err(DocxError::Limit(format!("part {name} decompresses past the size limit")));
|
||||
}
|
||||
total = total.saturating_add(buf.len() as u64);
|
||||
files.insert(name, Arc::new(buf));
|
||||
}
|
||||
Ok(Package { files })
|
||||
Ok(Package::new(files))
|
||||
}
|
||||
|
||||
/// A package from parts already in memory (a Flat OPC file, a converted document), by part
|
||||
/// name without the leading `/`.
|
||||
pub fn from_parts(files: BTreeMap<String, Vec<u8>>) -> Package {
|
||||
Package { files: files.into_iter().map(|(name, bytes)| (name, Arc::new(bytes))).collect() }
|
||||
Package::new(files.into_iter().map(|(name, bytes)| (name, Arc::new(bytes))).collect())
|
||||
}
|
||||
|
||||
fn new(mut files: BTreeMap<String, Arc<Vec<u8>>>) -> Package {
|
||||
let mut lower: HashMap<String, String> = HashMap::new();
|
||||
let mut collides = false;
|
||||
for name in files.keys() {
|
||||
match lower.entry(name.to_ascii_lowercase()) {
|
||||
Entry::Occupied(_) => collides = true,
|
||||
Entry::Vacant(v) => {
|
||||
v.insert(name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if !collides {
|
||||
return Package { files, lower };
|
||||
}
|
||||
// Entries whose names differ only in case are one part, so every spelling of the name
|
||||
// reaches the same stored entry (#3704). The spelling kept is the one a relationship
|
||||
// names, so the part the document points at is the one read; the first in byte order only
|
||||
// when no relationship names any of them.
|
||||
let named = rel_targets(&files);
|
||||
for name in files.keys().rev().filter(|n| named.contains(n.as_str())) {
|
||||
lower.insert(name.to_ascii_lowercase(), name.clone());
|
||||
}
|
||||
let before = files.len();
|
||||
files.retain(|name, _| lower.get(&name.to_ascii_lowercase()) == Some(name));
|
||||
let dropped = before.saturating_sub(files.len());
|
||||
log::warn!("docx: {dropped} zip entries are other spellings of parts already read; they are left out");
|
||||
Package { files, lower }
|
||||
}
|
||||
|
||||
/// The stored spelling of part `name` (leading `/` ignored; case-insensitive).
|
||||
pub fn canonical(&self, name: &str) -> Option<&str> {
|
||||
self.lower.get(&name.trim_start_matches('/').to_ascii_lowercase()).map(String::as_str)
|
||||
}
|
||||
|
||||
/// Part bytes by name (leading `/` ignored; case-insensitive fallback).
|
||||
@@ -251,11 +291,7 @@ impl Package {
|
||||
/// [`Package::get`], as the shared bytes themselves: for a part the document keeps whole, which
|
||||
/// then costs no copy (a picture document's pictures used to be held twice while it opened).
|
||||
pub fn get_shared(&self, name: &str) -> Option<&Arc<Vec<u8>>> {
|
||||
let n = name.trim_start_matches('/');
|
||||
if let Some(v) = self.files.get(n) {
|
||||
return Some(v);
|
||||
}
|
||||
self.files.iter().find(|(k, _)| k.eq_ignore_ascii_case(n)).map(|(_, v)| v)
|
||||
self.canonical(name).and_then(|n| self.files.get(n))
|
||||
}
|
||||
|
||||
/// [`Package::xml`] for the main document part: malformed XML is an error, not a truncated read.
|
||||
@@ -289,7 +325,14 @@ impl Package {
|
||||
for r in root.els().filter(|e| e.local() == "Relationship") {
|
||||
let (Some(id), Some(target)) = (r.attr("Id"), r.attr("Target")) else { continue };
|
||||
let external = r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External"));
|
||||
let resolved = if external { target.to_string() } else { resolve(dir, target) };
|
||||
// Internal targets name the part as stored, so a part reached under several spellings
|
||||
// is kept, cached and written once (#3704).
|
||||
let resolved = if external {
|
||||
target.to_string()
|
||||
} else {
|
||||
let path = resolve(dir, target);
|
||||
self.canonical(&path).map_or(path, str::to_string)
|
||||
};
|
||||
rels.list.push(Rel { id: id.to_string(), kind: r.attr("Type").unwrap_or("").to_string(), target: resolved, external });
|
||||
}
|
||||
rels
|
||||
@@ -364,6 +407,24 @@ fn split_dir(part: &str) -> (&str, &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every internal part name the relationship parts in `files` target, spelled as they spell it.
|
||||
fn rel_targets(files: &BTreeMap<String, Arc<Vec<u8>>>) -> HashSet<String> {
|
||||
let mut named = HashSet::new();
|
||||
for (name, bytes) in files {
|
||||
// `dir/_rels/file.rels` relates `dir/file`, so its targets resolve against `dir`.
|
||||
let Some(at) = name.rfind("_rels/").filter(|_| name.to_ascii_lowercase().ends_with(".rels")) else { continue };
|
||||
let dir = name.get(..at).unwrap_or("").trim_end_matches('/');
|
||||
let Ok(root) = xml::parse(bytes) else { continue };
|
||||
for r in root.els().filter(|e| e.local() == "Relationship") {
|
||||
let external = r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External"));
|
||||
if let (Some(target), false) = (r.attr("Target"), external) {
|
||||
named.insert(resolve(dir, target));
|
||||
}
|
||||
}
|
||||
}
|
||||
named
|
||||
}
|
||||
|
||||
/// Resolve a relative target against a source directory into a package path.
|
||||
pub fn resolve(dir: &str, target: &str) -> String {
|
||||
let target = target.split('#').next().unwrap_or(target);
|
||||
@@ -420,6 +481,39 @@ mod tests {
|
||||
zip_entries(&e).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_colliding_entries_are_one_part() {
|
||||
let z = zip_of(&[("word/media/Image.png", b"upper".to_vec()), ("word/media/image.png", b"lower".to_vec())]);
|
||||
let p = Package::open(&z).unwrap();
|
||||
for spelling in ["word/media/Image.png", "word/media/image.png", "/WORD/MEDIA/IMAGE.PNG"] {
|
||||
assert_eq!(p.canonical(spelling), Some("word/media/Image.png"), "{spelling}");
|
||||
assert_eq!(p.get(spelling), Some(&b"upper"[..]), "{spelling}");
|
||||
}
|
||||
assert_eq!(p.files.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_spelling_a_relationship_names_is_kept() {
|
||||
// The first spelling in byte order ("Image.png") was kept even when the document's
|
||||
// relationship named the other one, so the picture shown was not the one it pointed at.
|
||||
let rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="image" Target="media/image.png"/></Relationships>"#;
|
||||
let z = zip_of(&[
|
||||
("word/_rels/document.xml.rels", rels.as_bytes().to_vec()),
|
||||
("word/media/Image.png", b"upper".to_vec()),
|
||||
("word/media/image.png", b"lower".to_vec()),
|
||||
("word/media/Other.png", b"first".to_vec()),
|
||||
("word/media/other.png", b"second".to_vec()),
|
||||
]);
|
||||
let (p, warned) = crate::test_log::warnings(|| Package::open(&z).unwrap());
|
||||
assert_eq!(p.canonical("word/media/IMAGE.png"), Some("word/media/image.png"));
|
||||
assert_eq!(p.get("word/media/Image.png"), Some(&b"lower"[..]));
|
||||
assert_eq!(p.get("word/media/OTHER.PNG"), Some(&b"first"[..]), "no relationship names it: first in byte order");
|
||||
assert_eq!(p.files.len(), 3);
|
||||
let folded: Vec<_> = warned.iter().filter(|w| w.contains("other spellings")).collect();
|
||||
assert_eq!(folded.len(), 1, "one warning for the package: {warned:?}");
|
||||
assert!(folded.iter().all(|w| w.contains("2 zip entries")), "{folded:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompression_bomb_is_rejected() {
|
||||
let z = zip_of(&[("word/document.xml", vec![b' '; 2_000_000])]);
|
||||
@@ -429,6 +523,26 @@ mod tests {
|
||||
assert!(Package::open_limited(&z, 10_000_000, 10_000_000).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_that_fail_their_check_still_count_against_the_total() {
|
||||
// Each entry inflates in full before its CRC is checked; a failed one was dropped without
|
||||
// being charged, so any number of them inflated (#3723).
|
||||
let names: Vec<String> = (0..20).map(|i| format!("p{i}.bin")).collect();
|
||||
let entries: Vec<(&str, Vec<u8>)> = names.iter().map(|n| (n.as_str(), vec![0u8; 1_000_000])).collect();
|
||||
let mut z = zip_of(&entries);
|
||||
let mut patched = 0;
|
||||
for sig in [[0x50u8, 0x4B, 0x01, 0x02], [0x50, 0x4B, 0x03, 0x04]] {
|
||||
let crc_at = if sig[2] == 1 { 16 } else { 14 };
|
||||
let found: Vec<usize> = z.windows(4).enumerate().filter(|(_, w)| *w == sig).map(|(i, _)| i).collect();
|
||||
for pos in found {
|
||||
z[pos + crc_at] ^= 0xFF;
|
||||
patched += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(patched, 40);
|
||||
assert!(matches!(Package::open_limited(&z, 2_000_000, 4_000_000), Err(DocxError::Limit(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lying_declared_size_is_rejected_or_bounded() {
|
||||
let mut z = zip_of(&[("a.xml", b"<a/>".to_vec())]);
|
||||
|
||||
@@ -1212,6 +1212,7 @@ impl Reader<'_> {
|
||||
s.compat_mode = m.clamp(11, 99);
|
||||
}
|
||||
s.sum_paragraph_spacing = k.children("w:doNotUseHTMLParagraphAutoSpacing").any(on_off);
|
||||
s.do_not_expand_shift_return = k.children("w:doNotExpandShiftReturn").any(on_off);
|
||||
}
|
||||
"w:trackRevisions" => s.track_changes = on_off(k),
|
||||
"w:doNotTrackMoves" => s.track_moves = !on_off(k),
|
||||
@@ -1362,6 +1363,10 @@ impl Reader<'_> {
|
||||
let mut by_para: HashMap<String, u32> = HashMap::new();
|
||||
let mut next_id = 0u32;
|
||||
let mut entries: BTreeMap<u32, Comment> = BTreeMap::new();
|
||||
// Ids given before this part, looked up rather than scanned for every generated id: a
|
||||
// scan per comment made many non-numeric ids quadratic (#3692). Ids given here are in
|
||||
// `entries`.
|
||||
let given: HashSet<u32> = self.comment_map.values().copied().collect();
|
||||
for c in root.children("w:comment") {
|
||||
if self.doc.parts.len() >= MAX_PARTS {
|
||||
break;
|
||||
@@ -1373,7 +1378,7 @@ impl Reader<'_> {
|
||||
let id = match file_id.parse::<u32>() {
|
||||
Ok(v) if !entries.contains_key(&v) => v,
|
||||
_ => {
|
||||
while entries.contains_key(&next_id) || self.comment_map.values().any(|v| *v == next_id) {
|
||||
while entries.contains_key(&next_id) || given.contains(&next_id) {
|
||||
next_id += 1;
|
||||
}
|
||||
next_id
|
||||
|
||||
@@ -724,22 +724,28 @@ impl Reader<'_> {
|
||||
if sc.fields.iter().any(|f| f.phase == Phase::Instr) {
|
||||
return;
|
||||
}
|
||||
let c = match k.attr("w:type") {
|
||||
let ty = k.attr("w:type").map(|t| t.trim().to_ascii_lowercase());
|
||||
let c = match ty.as_deref() {
|
||||
Some("page") => "\u{000C}",
|
||||
Some("column") => "\u{000E}",
|
||||
_ => "\n",
|
||||
};
|
||||
self.emit_text(sc, pb, c, props);
|
||||
}
|
||||
"w:cr" => {
|
||||
if !sc.fields.iter().any(|f| f.phase == Phase::Instr) {
|
||||
self.emit_text(sc, pb, "\n", props);
|
||||
}
|
||||
"w:cr" if !sc.fields.iter().any(|f| f.phase == Phase::Instr) => {
|
||||
self.emit_text(sc, pb, "\n", props);
|
||||
}
|
||||
"w:cr" => {}
|
||||
"w:noBreakHyphen" => self.emit_text(sc, pb, "\u{2011}", props),
|
||||
"w:softHyphen" => self.emit_text(sc, pb, "\u{00AD}", props),
|
||||
"w:sym" => {
|
||||
let ch = k.attr("w:char").and_then(|h| u32::from_str_radix(h.trim(), 16).ok()).and_then(char::from_u32).filter(|c| *c as u32 >= 0x20);
|
||||
let h_str = k.attr("w:char").map(|h| {
|
||||
let s = h.trim();
|
||||
let s = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")).unwrap_or(s);
|
||||
let s = s.strip_prefix("U+").or_else(|| s.strip_prefix("u+")).unwrap_or(s);
|
||||
s.strip_prefix("#x").or_else(|| s.strip_prefix("#X")).unwrap_or(s)
|
||||
});
|
||||
let ch = h_str.and_then(|h| u32::from_str_radix(h, 16).ok()).and_then(char::from_u32).filter(|c| *c as u32 >= 0x20);
|
||||
if let Some(ch) = ch {
|
||||
let mut p = props.clone();
|
||||
// The symbol's own font, kept apart from the run's (ECMA-376 §17.3.3.30).
|
||||
@@ -783,7 +789,7 @@ impl Reader<'_> {
|
||||
}
|
||||
}
|
||||
"w:footnoteReference" | "w:endnoteReference" => {
|
||||
let foot = k.name == "w:footnoteReference";
|
||||
let foot = k.local() == "footnoteReference";
|
||||
let fid = k.attr("w:id").and_then(int).unwrap_or(i64::MIN);
|
||||
let map = if foot { &self.footnotes } else { &self.endnotes };
|
||||
if let Some(id) = map.get(&fid).copied() {
|
||||
@@ -794,7 +800,7 @@ impl Reader<'_> {
|
||||
}
|
||||
// The note's own number at the start of its text: a reference to the note being read.
|
||||
"w:footnoteRef" | "w:endnoteRef" => {
|
||||
let kind = if k.name == "w:footnoteRef" { NoteKind::Footnote } else { NoteKind::Endnote };
|
||||
let kind = if k.local() == "footnoteRef" { NoteKind::Footnote } else { NoteKind::Endnote };
|
||||
if let Some((nk, id)) = self.current_note
|
||||
&& nk == kind
|
||||
{
|
||||
@@ -2291,7 +2297,7 @@ fn css_string(v: &str) -> String {
|
||||
out.push(d);
|
||||
}
|
||||
// One whitespace after the escape is its terminator, not part of the value.
|
||||
if chars.clone().next().is_some_and(|w| w.is_whitespace()) {
|
||||
if chars.clone().next().is_some_and(char::is_whitespace) {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Test helper: the warnings this crate logs, captured per test thread, so a test can check that
|
||||
//! a limit which leaves something out says so.
|
||||
|
||||
use std::cell::RefCell;
|
||||
|
||||
struct Capture;
|
||||
|
||||
thread_local! {
|
||||
static LINES: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
impl log::Log for Capture {
|
||||
fn enabled(&self, m: &log::Metadata) -> bool {
|
||||
m.level() <= log::Level::Warn
|
||||
}
|
||||
|
||||
fn log(&self, r: &log::Record) {
|
||||
if self.enabled(r.metadata()) {
|
||||
LINES.with(|l| l.borrow_mut().push(r.args().to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
static CAPTURE: Capture = Capture;
|
||||
|
||||
/// `f`'s result and the warnings logged on this thread while it ran.
|
||||
pub(crate) fn warnings<T>(f: impl FnOnce() -> T) -> (T, Vec<String>) {
|
||||
// Only this logger is ever installed in a test binary; a second call keeps it.
|
||||
let _ = log::set_logger(&CAPTURE);
|
||||
log::set_max_level(log::LevelFilter::Warn);
|
||||
LINES.with(|l| l.borrow_mut().clear());
|
||||
let out = f();
|
||||
(out, LINES.with(|l| std::mem::take(&mut *l.borrow_mut())))
|
||||
}
|
||||
@@ -48,6 +48,10 @@ pub(crate) struct EmbedWriter<'d> {
|
||||
pub parts: Vec<(String, Vec<u8>, String, PartRels)>,
|
||||
/// Leave out the main document's kept glossary (it's written from the model instead).
|
||||
pub drop_glossary: bool,
|
||||
/// Kept parts written with these bytes instead of their own (see [`Self::scrub_glossary`]).
|
||||
scrubbed: HashMap<String, Vec<u8>>,
|
||||
/// A chart past the chart limits has been written (and the loss logged, once per save).
|
||||
chart_cut: bool,
|
||||
}
|
||||
|
||||
impl<'d> EmbedWriter<'d> {
|
||||
@@ -68,7 +72,7 @@ impl<'d> EmbedWriter<'d> {
|
||||
"[content_types].xml",
|
||||
]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.map(ToString::to_string)
|
||||
.collect();
|
||||
taken.insert(VBA_PROJECT_PART.to_ascii_lowercase());
|
||||
// The bibliography writer uses these names when sources are present (#532).
|
||||
@@ -93,6 +97,8 @@ impl<'d> EmbedWriter<'d> {
|
||||
markup_bytes: MAX_MARKUP_BYTES,
|
||||
parts: Vec::new(),
|
||||
drop_glossary: false,
|
||||
scrubbed: HashMap::new(),
|
||||
chart_cut: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +236,44 @@ impl<'d> EmbedWriter<'d> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Have the main document's kept glossary, and the parts it reaches, written without the
|
||||
/// personal information (`names`) and dates the privacy settings remove (#3692). False when a
|
||||
/// part's markup can't be rewritten: the glossary is then written from the model.
|
||||
pub fn scrub_glossary(&mut self, names: bool, dates: bool) -> bool {
|
||||
let Some(roots) = self.passthrough.get(crate::package::UNMODELLED_ROOTS) else { return true };
|
||||
let mut todo: Vec<String> = String::from_utf8_lossy(roots)
|
||||
.lines()
|
||||
.take(MAX_EMBEDDED_RELS * 2)
|
||||
.filter_map(|line| match line.split('\t').collect::<Vec<_>>().as_slice() {
|
||||
[source, kind, target] if *source == "D" && rel_is(kind, rt::GLOSSARY) => Some(target.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
while let Some(p) = todo.pop() {
|
||||
if seen.len() >= MAX_EMBEDDED_PARTS || !seen.insert(p.clone()) {
|
||||
continue;
|
||||
}
|
||||
let (Some(info), Some(bytes)) = (self.manifest.parts.get(&p), self.passthrough.get(&p)) else { continue };
|
||||
todo.extend(info.rels.iter().filter(|r| !r.external).map(|r| r.target.clone()));
|
||||
if !(info.content_type.ends_with("xml") || p.to_ascii_lowercase().ends_with(".xml")) {
|
||||
continue;
|
||||
}
|
||||
match scrub_private(bytes, names, dates) {
|
||||
Some(b) => {
|
||||
self.scrubbed.insert(p, b);
|
||||
}
|
||||
None => {
|
||||
log::warn!(
|
||||
"docx: the glossary's part {p} can't be cleaned of personal information; the glossary is written from its entries, without what WordCraft doesn't model"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// What copying the parts reached from `roots` costs against the copy budget.
|
||||
fn tree_cost(&self, roots: &[&str]) -> u64 {
|
||||
let mut seen: HashSet<&str> = HashSet::new();
|
||||
@@ -283,8 +327,9 @@ impl<'d> EmbedWriter<'d> {
|
||||
rels.add_with_id(&r.id, &r.kind, &relative(&new, &target), false);
|
||||
}
|
||||
let ct = if info.content_type.is_empty() { "application/octet-stream".to_string() } else { info.content_type };
|
||||
let bytes = self.scrubbed.get(&orig).cloned().unwrap_or_else(|| bytes.to_vec());
|
||||
written.push(self.parts.len());
|
||||
self.parts.push((new, bytes.to_vec(), ct, rels));
|
||||
self.parts.push((new, bytes, ct, rels));
|
||||
}
|
||||
Some(name)
|
||||
}
|
||||
@@ -408,7 +453,12 @@ impl super::Writer<'_> {
|
||||
/// related from the story part through `rels`.
|
||||
pub(super) fn chart_graphic(&mut self, spec: &wordcraft_doc::chart::ChartSpec, rels: &mut PartRels) -> String {
|
||||
let name = self.embeds.fresh("word/charts/chart", ".xml");
|
||||
let bytes = crate::chart_spec::chart_xml(spec).into_bytes();
|
||||
let (xml, cut) = crate::chart_spec::chart_part(spec);
|
||||
if cut && !self.embeds.chart_cut {
|
||||
self.embeds.chart_cut = true;
|
||||
log::warn!("docx: a chart has more series or points than a chart part holds; the rest are left out of the file");
|
||||
}
|
||||
let bytes = xml.into_bytes();
|
||||
self.embeds.parts.push((name.clone(), bytes, crate::chart_spec::CHART_CT.to_string(), PartRels::default()));
|
||||
let id = rels.add(crate::package::rt::CHART, &relative(STORY_PART, &name), false);
|
||||
let mut w = xml::W::default();
|
||||
@@ -640,6 +690,80 @@ fn patch_rel_ids(bytes: &[u8], ids: &HashMap<String, String>) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
/// `bytes` (kept XML) without the personal information and dates the privacy settings remove, as
|
||||
/// the writer leaves them out of the parts it writes: annotation authors become "Author" and
|
||||
/// comment initials "A" (`names`); annotation dates and UTC dates go (`dates`); and with `names`,
|
||||
/// a people part's authors become "Author" and their presence information goes. Attributes are
|
||||
/// matched by namespace, so any prefix is caught. `None` when the markup can't be rewritten
|
||||
/// (malformed, or not UTF-8).
|
||||
fn scrub_private(bytes: &[u8], names: bool, dates: bool) -> Option<Vec<u8>> {
|
||||
use std::borrow::Cow;
|
||||
|
||||
use quick_xml::events::Event;
|
||||
use quick_xml::events::attributes::Attribute;
|
||||
use quick_xml::name::{QName, ResolveResult};
|
||||
use quick_xml::reader::NsReader;
|
||||
use quick_xml::writer::Writer;
|
||||
|
||||
const WML: &[u8] = b"http://schemas.openxmlformats.org/wordprocessingml/2006/main";
|
||||
const WML_STRICT: &[u8] = b"http://purl.oclc.org/ooxml/wordprocessingml/main";
|
||||
const W15: &[u8] = b"http://schemas.microsoft.com/office/word/2012/wordml";
|
||||
// UTF-16 (or other wide encodings) would pass through unread.
|
||||
if bytes.contains(&0) || bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]) {
|
||||
return None;
|
||||
}
|
||||
let ns_is = |r: &ResolveResult, ns: &[u8]| matches!(r, ResolveResult::Bound(n) if n.as_ref() == ns);
|
||||
let wml = |r: &ResolveResult| ns_is(r, WML) || ns_is(r, WML_STRICT);
|
||||
let mut r = NsReader::from_reader(bytes);
|
||||
r.config_mut().check_end_names = false;
|
||||
let mut out = Writer::new(Vec::with_capacity(bytes.len()));
|
||||
let mut buf = Vec::new();
|
||||
let mut skip = Vec::new();
|
||||
loop {
|
||||
buf.clear();
|
||||
let (res, ev) = r.read_resolved_event_into(&mut buf).ok()?;
|
||||
let (in_wml, in_w15) = (wml(&res), ns_is(&res, W15));
|
||||
let ev = match ev {
|
||||
Event::Eof => break,
|
||||
Event::Start(ref e) | Event::Empty(ref e) if names && in_w15 && e.local_name().as_ref() == b"presenceInfo" => {
|
||||
if matches!(ev, Event::Start(_)) {
|
||||
let end = e.name().as_ref().to_vec();
|
||||
r.read_to_end_into(QName(&end), &mut skip).ok()?;
|
||||
skip.clear();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Event::Start(ref e) | Event::Empty(ref e) => {
|
||||
let mut el = e.to_owned();
|
||||
el.clear_attributes();
|
||||
for a in e.attributes().with_checks(false) {
|
||||
let a = a.ok()?;
|
||||
let (ares, local) = r.resolver_mut().resolve_attribute(a.key);
|
||||
// An unprefixed attribute of a WordprocessingML element is read as its own.
|
||||
let wml_attr = wml(&ares) || (matches!(ares, ResolveResult::Unbound) && in_wml);
|
||||
let value: Option<&[u8]> = match local.as_ref() {
|
||||
b"author" if names && (wml_attr || ns_is(&ares, W15)) => Some(super::ANONYMOUS_AUTHOR.as_bytes()),
|
||||
b"initials" if names && wml_attr => Some(super::ANONYMOUS_INITIALS.as_bytes()),
|
||||
b"date" if dates && wml_attr => None,
|
||||
b"dateUtc" if dates => None,
|
||||
_ => {
|
||||
el.push_attribute(a);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if let Some(v) = value {
|
||||
el.push_attribute(Attribute { key: a.key, value: Cow::Borrowed(v) });
|
||||
}
|
||||
}
|
||||
if matches!(ev, Event::Start(_)) { Event::Start(el) } else { Event::Empty(el) }
|
||||
}
|
||||
ev => ev,
|
||||
};
|
||||
out.write_event(ev).ok()?;
|
||||
}
|
||||
Some(out.into_inner())
|
||||
}
|
||||
|
||||
/// A VML `style` with the object's size, rotation and flips, and its offset when it floats
|
||||
/// (`position:absolute`).
|
||||
fn vml_style_with(style: &str, w: f32, h: f32, float: &Float) -> String {
|
||||
@@ -709,6 +833,40 @@ fn valid_part_name(name: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn charts_cut_to_the_limits_on_save_say_so_once() {
|
||||
// A model deserialised as is can hold more than a chart part keeps; the save left the rest
|
||||
// out without a word (review of #3872).
|
||||
use std::sync::Arc;
|
||||
use wordcraft_doc::chart::{ChartSeries, ChartSpec, ChartType, MAX_SERIES};
|
||||
use wordcraft_doc::graphic::{Graphic, GraphicKind};
|
||||
use wordcraft_doc::{CharProps, InlineObject, Pos};
|
||||
let chart = |series: usize| {
|
||||
let spec = ChartSpec {
|
||||
kind: ChartType::Column,
|
||||
categories: vec!["a".into(), "b".into()],
|
||||
series: (0..series).map(|i| ChartSeries { name: format!("S{i}"), values: vec![Some(1.0); 2] }).collect(),
|
||||
title: None,
|
||||
legend: None,
|
||||
data_labels: false,
|
||||
};
|
||||
let graphic = Graphic { kind: GraphicKind::Chart, w: 100.0, h: 100.0, chart: Some(Arc::new(spec)), ..Default::default() };
|
||||
InlineObject::Graphic { w: 100.0, h: 100.0, alt: String::new(), float: Default::default(), graphic: Arc::new(graphic) }
|
||||
};
|
||||
let save = |charts: Vec<InlineObject>| {
|
||||
let mut doc = wordcraft_doc::Document::new();
|
||||
for c in charts {
|
||||
doc.insert_object(&Pos::body(0, 0), c, &CharProps::default()).unwrap();
|
||||
}
|
||||
crate::test_log::warnings(|| crate::write(&doc).unwrap()).1
|
||||
};
|
||||
let cut = |w: &Vec<String>| w.iter().filter(|w| w.contains("chart")).count();
|
||||
let warned = save(vec![chart(MAX_SERIES + 1), chart(MAX_SERIES + 5), chart(2)]);
|
||||
assert_eq!(cut(&warned), 1, "one warning for the save, not one per chart: {warned:?}");
|
||||
let warned = save(vec![chart(3)]);
|
||||
assert_eq!(cut(&warned), 0, "nothing left out: {warned:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_part_drawing_ids_are_rewritten_once() {
|
||||
let ids: HashMap<String, String> = [("rId9".to_string(), "rId2".to_string()), ("rId2".to_string(), "rId3".to_string())].into_iter().collect();
|
||||
|
||||
@@ -272,7 +272,13 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result<Vec<(String,
|
||||
|
||||
// The glossary: kept verbatim while its entries are as read and its parts were kept, else
|
||||
// written from the model (before the notes: references its entries hold are collected too).
|
||||
if glossary::edited(doc) || (!doc.glossary.is_empty() && !wr.embeds.kept_glossary()) {
|
||||
// When the privacy settings ask for changes, its kept parts are written without the names and
|
||||
// dates of its tracked changes and comments (#3692), or from the model if they can't be.
|
||||
let (names, dates) = (doc.settings.remove_personal_info, doc.settings.remove_date_time);
|
||||
if glossary::edited(doc)
|
||||
|| (!doc.glossary.is_empty() && !wr.embeds.kept_glossary())
|
||||
|| ((names || dates) && !wr.embeds.scrub_glossary(names, dates))
|
||||
{
|
||||
wr.embeds.drop_glossary = true;
|
||||
wr.glossary(&mut entries, &mut overrides, &mut rels);
|
||||
}
|
||||
@@ -1083,6 +1089,9 @@ fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool, template_rel: O
|
||||
}
|
||||
w.open("w:compat", &[]);
|
||||
// CT_Compat: the older switches come before the `w:compatSetting`s.
|
||||
if doc.settings.do_not_expand_shift_return {
|
||||
w.empty("w:doNotExpandShiftReturn", &[]);
|
||||
}
|
||||
if doc.settings.sum_paragraph_spacing {
|
||||
w.empty("w:doNotUseHTMLParagraphAutoSpacing", &[]);
|
||||
}
|
||||
@@ -1228,7 +1237,7 @@ fn theme_xml(doc: &Document) -> Vec<u8> {
|
||||
/// The author the saved file gives a comment or tracked change when the document removes personal
|
||||
/// information on save (`w:removePersonalInformation`), as Word does.
|
||||
pub(crate) const ANONYMOUS_AUTHOR: &str = "Author";
|
||||
const ANONYMOUS_INITIALS: &str = "A";
|
||||
pub(crate) const ANONYMOUS_INITIALS: &str = "A";
|
||||
|
||||
/// The `w:author` written for an annotation by `author`: "Author" when the document removes
|
||||
/// personal information on save, "Unknown" when it has none.
|
||||
|
||||
@@ -15,6 +15,8 @@ use crate::DocxError;
|
||||
pub const MAX_DEPTH: usize = 256;
|
||||
/// Maximum number of elements in one part.
|
||||
pub const MAX_ELEMENTS: usize = 8_000_000;
|
||||
/// Maximum number of attributes on one element (#3702).
|
||||
pub const MAX_ATTRS: usize = 1024;
|
||||
|
||||
/// Known namespaces and the canonical prefix we use for them (read and write).
|
||||
pub const NAMESPACES: &[(&str, &str)] = &[
|
||||
@@ -360,6 +362,9 @@ fn parse_with(bytes: &[u8], extra: &[(&'static str, &str)], strict: bool) -> Res
|
||||
};
|
||||
let mut el = El { name, attrs: Vec::new(), kids: Vec::new() };
|
||||
for a in e.attributes().with_checks(false) {
|
||||
if el.attrs.len() >= MAX_ATTRS {
|
||||
return Err(DocxError::Limit("too many attributes on one element".into()));
|
||||
}
|
||||
let Ok(a) = a else { continue };
|
||||
let key = a.key;
|
||||
let raw_key = key.as_ref();
|
||||
@@ -669,6 +674,39 @@ mod tests {
|
||||
assert!(parse(s.as_bytes()).is_err());
|
||||
}
|
||||
|
||||
/// One start tag carrying 200,000 attributes (distinct or duplicate names) is refused or
|
||||
/// truncated instead of allocated: a 59 KB `.docx` used to cost 611 MB (#3702, WC-06).
|
||||
#[test]
|
||||
fn too_many_attributes_on_one_element_is_capped() {
|
||||
// The cap the issue suggests; a fix may pick a lower one, never a higher one.
|
||||
const CAP: usize = 1024;
|
||||
let distinct: String = (0..200_000).map(|i| format!(" a{i}=\"\"")).collect();
|
||||
let duplicate = " a=\"\"".repeat(200_000);
|
||||
for (label, attrs) in [("distinct", distinct), ("duplicate", duplicate)] {
|
||||
let x = format!("<w:p xmlns:w=\"{}\"{attrs}/>", NAMESPACES[0].1);
|
||||
match parse(x.as_bytes()) {
|
||||
Err(DocxError::Limit(_)) => {}
|
||||
Ok(p) => assert!(p.attrs.len() <= CAP, "{label}: kept {} attributes", p.attrs.len()),
|
||||
Err(e) => panic!("{label}: expected a Limit error or a capped element, got {e}"),
|
||||
}
|
||||
}
|
||||
// Real-world attribute counts are untouched.
|
||||
let x = format!(
|
||||
r#"<w:p xmlns:w="{}" w:rsidR="00A1" w:rsidRDefault="00A1" w:rsidP="00A1" w:rsidRPr="00A1"><w:rPr><w:rFonts w:ascii="A" w:hAnsi="B" w:eastAsia="C" w:cs="D" w:asciiTheme="minorHAnsi" w:hAnsiTheme="minorHAnsi" w:eastAsiaTheme="minorEastAsia" w:cstheme="minorBidi" w:hint="eastAsia"/></w:rPr></w:p>"#,
|
||||
NAMESPACES[0].1
|
||||
);
|
||||
let p = parse(x.as_bytes()).unwrap();
|
||||
assert_eq!(p.attrs.len(), 4);
|
||||
let fonts = p.child("w:rPr").and_then(|r| r.child("w:rFonts")).unwrap();
|
||||
assert_eq!(fonts.attrs.len(), 9);
|
||||
assert_eq!(fonts.attr("w:cstheme"), Some("minorBidi"));
|
||||
// Far more than any real producer writes, still well under any sane cap.
|
||||
let many: String = (0..256).map(|i| format!(" a{i}=\"{i}\"")).collect();
|
||||
let p = parse(format!("<p{many}/>").as_bytes()).unwrap();
|
||||
assert_eq!(p.attrs.len(), 256);
|
||||
assert_eq!(p.attr("a255"), Some("255"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_is_error_not_panic() {
|
||||
assert!(parse(b"").is_err());
|
||||
|
||||
@@ -48,6 +48,40 @@ fn read_body(body: &str) -> Document {
|
||||
wordcraft_docx::read(&docx(body, &[], &[])).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_style_ids_use_last() {
|
||||
let styles = format!(
|
||||
r#"<w:styles {W_NS}>
|
||||
<w:style w:type="paragraph" w:styleId="Repeated"><w:name w:val="First"/>
|
||||
<w:pPr><w:spacing w:after="240"/></w:pPr><w:rPr><w:b/><w:smallCaps/><w:sz w:val="28"/></w:rPr>
|
||||
</w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Repeated"><w:name w:val="Last"/>
|
||||
<w:pPr><w:spacing w:before="120"/></w:pPr><w:rPr><w:i/><w:caps/><w:sz w:val="20"/></w:rPr>
|
||||
</w:style></w:styles>"#
|
||||
);
|
||||
let bytes = docx(
|
||||
r#"<w:p><w:pPr><w:pStyle w:val="Repeated"/></w:pPr><w:r><w:t>Original style probe</w:t></w:r></w:p>"#,
|
||||
&[("styles", "styles", "styles.xml")],
|
||||
&[("word/styles.xml", &styles)],
|
||||
);
|
||||
let mut document = wordcraft_docx::read(&bytes).unwrap();
|
||||
for round in 0..=2 {
|
||||
let style = document.styles.get("Repeated").unwrap();
|
||||
assert_eq!(style.name, "Last");
|
||||
assert_eq!(style.chr.size, Some(10.0));
|
||||
assert_eq!(style.chr.italic, Some(true));
|
||||
assert_eq!(style.chr.bold, None);
|
||||
assert_eq!(style.chr.caps, Some(true));
|
||||
assert_eq!(style.chr.small_caps, None);
|
||||
assert_eq!(style.para.space_before, Some(6.0));
|
||||
assert_eq!(style.para.space_after, None);
|
||||
assert_eq!(document.styles.styles.iter().filter(|style| style.id == "Repeated").count(), 1);
|
||||
if round < 2 {
|
||||
document = wordcraft_docx::read(&wordcraft_docx::write(&document).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_default_presence_survives_repeated_saves() {
|
||||
// Omission leaves paragraph defaults application-defined (ISO 29500 pPrDefault).
|
||||
@@ -339,6 +373,18 @@ fn pcl6_symbol_roundtrip() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sym_hex_prefixes_and_break_type_casing() {
|
||||
let body = r##"
|
||||
<w:p>
|
||||
<w:r><w:sym w:font="Wingdings" w:char="0xF0FC"/><w:sym w:font="Symbol" w:char="U+F061"/><w:sym w:font="Webdings" w:char="#xF061"/></w:r>
|
||||
<w:r><w:br w:type="Page"/><w:br w:type="Column"/><w:br w:type="textWrapping"/></w:r>
|
||||
</w:p>"##;
|
||||
let d = wordcraft_docx::read(&docx(body, &[], &[])).unwrap();
|
||||
let p = paras(&d);
|
||||
assert_eq!(p[0].plain_text(), "\u{F0FC}\u{F061}\u{F061}\u{C}\u{E}\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternate_content_choice_and_fallback() {
|
||||
let body = r#"
|
||||
@@ -3572,6 +3618,22 @@ fn html_auto_spacing_off_is_read_and_kept() {
|
||||
assert_eq!(settings(""), (false, false));
|
||||
}
|
||||
|
||||
/// `w:doNotExpandShiftReturn` in the settings' `w:compat`: justified lines ending in a manual line
|
||||
/// break keep their spacing (#3924).
|
||||
#[test]
|
||||
fn do_not_expand_shift_return_is_read_and_kept() {
|
||||
let settings = |compat: &str| {
|
||||
let s = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:settings {W_NS}><w:compat>{compat}</w:compat></w:settings>"#);
|
||||
let rels = [("rId9", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings", "settings.xml")];
|
||||
let d = wordcraft_docx::read(&docx("<w:p/>", &rels, &[("word/settings.xml", &s)])).unwrap();
|
||||
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
|
||||
(d.settings.do_not_expand_shift_return, again.settings.do_not_expand_shift_return)
|
||||
};
|
||||
assert_eq!(settings("<w:doNotExpandShiftReturn/>"), (true, true));
|
||||
assert_eq!(settings(r#"<w:doNotExpandShiftReturn w:val="0"/>"#), (false, false));
|
||||
assert_eq!(settings(""), (false, false));
|
||||
}
|
||||
|
||||
/// A right triangle (`rtTriangle`) has its right angle at the bottom left: a cover page's white
|
||||
/// corner cut-outs drawn as isosceles triangles pointed the wrong way.
|
||||
#[test]
|
||||
|
||||
@@ -234,6 +234,131 @@ fn edited_glossary_is_written_from_the_model() {
|
||||
assert_eq!(twice.get(&target), files.get(&target));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unchanged_glossary_follows_the_privacy_settings() {
|
||||
// An untouched glossary was written back byte for byte, so the names and dates of its
|
||||
// tracked changes survived a save that removes personal information (#3692).
|
||||
let tracked = r#"<w:docPart><w:docPartPr><w:name w:val="Clause"/><w:category><w:name w:val="General"/><w:gallery w:val="docParts"/></w:category></w:docPartPr><w:docPartBody><w:p><w:ins w:id="1" w:author="Quill Ramsey" w:date="2026-03-04T05:06:07Z"><w:r><w:t>Added clause</w:t></w:r></w:ins></w:p></w:docPartBody></w:docPart>"#;
|
||||
let bytes = package(
|
||||
&[("rId7", GLOSSARY_REL, "glossary/document.xml")],
|
||||
&[
|
||||
("[Content_Types].xml", content_types(&[("/word/glossary/document.xml", GLOSSARY_CT)])),
|
||||
("word/glossary/document.xml", glossary_xml(tracked)),
|
||||
],
|
||||
);
|
||||
let doc = wordcraft_docx::read(&bytes).unwrap();
|
||||
let as_read = save(&doc);
|
||||
let kept = text(&as_read, &glossary_target(&as_read).expect("a glossary"));
|
||||
assert!(kept.contains("Quill Ramsey"), "the fixture's glossary is kept verbatim: {kept}");
|
||||
for (personal, dates) in [(true, false), (false, true), (true, true)] {
|
||||
let mut private = doc.clone();
|
||||
private.settings.remove_personal_info = personal;
|
||||
private.settings.remove_date_time = dates;
|
||||
let files = save(&private);
|
||||
let g = text(&files, &glossary_target(&files).expect("a glossary"));
|
||||
assert!(g.contains("Added clause"), "{g}");
|
||||
assert_eq!(g.contains("Quill Ramsey"), !personal, "{g}");
|
||||
assert_eq!(g.contains("2026-03-04T05:06:07Z"), !dates, "{g}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_private_save_keeps_the_glossary_entries_the_model_passed_over() {
|
||||
// The privacy settings had the glossary written from the model, which holds only the
|
||||
// entries the reader took: one without a name was lost, and with no other entry the whole
|
||||
// glossary went. The kept parts are now written without the names and dates instead.
|
||||
let nameless = r#"<w:docPart><w:docPartPr><w:category><w:name w:val="General"/><w:gallery w:val="docParts"/></w:category></w:docPartPr><w:docPartBody><w:p><w:ins w:id="1" w:author="Quill Ramsey" w:date="2026-03-04T05:06:07Z"><w:r><w:t>Nameless entry</w:t></w:r></w:ins><w:commentRangeStart w:id="0"/><w:r><w:t>noted</w:t></w:r><w:commentRangeEnd w:id="0"/><w:r><w:commentReference w:id="0"/></w:r></w:p></w:docPartBody></w:docPart>"#;
|
||||
// The comments part binds WordprocessingML to another prefix: names are matched by namespace.
|
||||
let comments = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><x:comments xmlns:x="{W}"><x:comment x:id="0" x:author="Quill Ramsey" x:date="2026-03-04T05:06:07Z" x:initials="QR"><x:p><x:r><x:t>A note</x:t></x:r></x:p></x:comment></x:comments>"#
|
||||
);
|
||||
let bytes = package(
|
||||
&[("rId7", GLOSSARY_REL, "glossary/document.xml")],
|
||||
&[
|
||||
(
|
||||
"[Content_Types].xml",
|
||||
content_types(&[
|
||||
("/word/glossary/document.xml", GLOSSARY_CT),
|
||||
("/word/glossary/comments.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"),
|
||||
]),
|
||||
),
|
||||
("word/glossary/document.xml", glossary_xml(nameless)),
|
||||
("word/glossary/_rels/document.xml.rels", rels(&[("rId1", &format!("{REL}/comments"), "comments.xml")])),
|
||||
("word/glossary/comments.xml", comments),
|
||||
],
|
||||
);
|
||||
let doc = wordcraft_docx::read(&bytes).unwrap();
|
||||
assert!(doc.glossary.is_empty(), "the reader passes over an entry without a name");
|
||||
for (personal, dates) in [(true, false), (false, true), (true, true)] {
|
||||
let mut private = doc.clone();
|
||||
private.settings.remove_personal_info = personal;
|
||||
private.settings.remove_date_time = dates;
|
||||
let files = save(&private);
|
||||
let target = glossary_target(&files).expect("the glossary survives");
|
||||
let g = text(&files, &target);
|
||||
assert!(g.contains("Nameless entry"), "{g}");
|
||||
assert_eq!(g.contains("Quill Ramsey"), !personal, "{g}");
|
||||
assert_eq!(g.contains("2026-03-04T05:06:07Z"), !dates, "{g}");
|
||||
let c = files.iter().find(|(n, _)| n.starts_with("word/glossary/comments")).map(|(_, b)| String::from_utf8_lossy(b).into_owned());
|
||||
let c = c.expect("the glossary's comments survive");
|
||||
assert!(c.contains("A note"), "{c}");
|
||||
assert_eq!(c.contains("Quill Ramsey"), !personal, "{c}");
|
||||
assert_eq!(c.contains("x:initials=\"QR\""), !personal, "{c}");
|
||||
assert_eq!(c.contains("2026-03-04T05:06:07Z"), !dates, "{c}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kept_glossary_that_cant_be_rewritten_privately_is_written_from_the_model() {
|
||||
// UTF-16 markup isn't rewritten in place: the model, which applies the settings, is written.
|
||||
let tracked = r#"<w:docPart><w:docPartPr><w:name w:val="Clause"/><w:category><w:name w:val="General"/><w:gallery w:val="docParts"/></w:category></w:docPartPr><w:docPartBody><w:p><w:ins w:id="1" w:author="Quill Ramsey" w:date="2026-03-04T05:06:07Z"><w:r><w:t>Added clause</w:t></w:r></w:ins></w:p></w:docPartBody></w:docPart>"#;
|
||||
let utf16: Vec<u8> = [0xFF, 0xFE].into_iter().chain(glossary_xml(tracked).encode_utf16().flat_map(u16::to_le_bytes)).collect();
|
||||
let bytes = zip(&[
|
||||
("_rels/.rels", rels(&[("rIdMain", &format!("{REL}/officeDocument"), "word/document.xml")]).into_bytes()),
|
||||
(
|
||||
"word/document.xml",
|
||||
format!(r#"<w:document xmlns:w="{W}"><w:body><w:p><w:r><w:t>Body</w:t></w:r></w:p></w:body></w:document>"#).into_bytes(),
|
||||
),
|
||||
("word/_rels/document.xml.rels", rels(&[("rId7", GLOSSARY_REL, "glossary/document.xml")]).into_bytes()),
|
||||
("[Content_Types].xml", content_types(&[("/word/glossary/document.xml", GLOSSARY_CT)]).into_bytes()),
|
||||
("word/glossary/document.xml", utf16),
|
||||
]);
|
||||
let mut doc = wordcraft_docx::read(&bytes).unwrap();
|
||||
assert_eq!(doc.glossary.len(), 1);
|
||||
doc.settings.remove_personal_info = true;
|
||||
let (files, warned) = warnings(|| save(&doc));
|
||||
let g = text(&files, &glossary_target(&files).expect("a glossary"));
|
||||
assert!(g.contains("Added clause") && !g.contains("Quill Ramsey"), "{g}");
|
||||
// What the model doesn't hold is lost: the save says so (review of #3872).
|
||||
assert_eq!(warned.iter().filter(|w| w.contains("written from its entries")).count(), 1, "{warned:?}");
|
||||
}
|
||||
|
||||
/// `f`'s result and the warnings logged on this thread while it ran.
|
||||
fn warnings<T>(f: impl FnOnce() -> T) -> (T, Vec<String>) {
|
||||
use std::cell::RefCell;
|
||||
struct Capture;
|
||||
thread_local! {
|
||||
static LINES: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
impl log::Log for Capture {
|
||||
fn enabled(&self, m: &log::Metadata) -> bool {
|
||||
m.level() <= log::Level::Warn
|
||||
}
|
||||
fn log(&self, r: &log::Record) {
|
||||
if self.enabled(r.metadata()) {
|
||||
LINES.with(|l| l.borrow_mut().push(r.args().to_string()));
|
||||
}
|
||||
}
|
||||
fn flush(&self) {}
|
||||
}
|
||||
static CAPTURE: Capture = Capture;
|
||||
let _ = log::set_logger(&CAPTURE);
|
||||
log::set_max_level(log::LevelFilter::Warn);
|
||||
LINES.with(|l| l.borrow_mut().clear());
|
||||
let out = f();
|
||||
(out, LINES.with(|l| std::mem::take(&mut *l.borrow_mut())))
|
||||
}
|
||||
|
||||
/// The relationships part of a part in `word/`.
|
||||
fn glossary_rels(part: &str) -> String {
|
||||
let (dir, file) = part.rsplit_once('/').unwrap();
|
||||
|
||||
@@ -150,6 +150,21 @@ fn many_fields_and_huge_attribute_values() {
|
||||
assert!(d.plain_text(wordcraft_doc::StoryRef::Body).contains("later"));
|
||||
}
|
||||
|
||||
/// A paragraph with 2,000,000 attributes deflates to ~15 KB. Opening it is refused with a
|
||||
/// limit error, or the extras are dropped and the text survives (#3702, WC-06).
|
||||
#[test]
|
||||
fn millions_of_attributes_on_one_paragraph_are_capped() {
|
||||
let attrs: String = (0..2_000_000).map(|i| format!(" a{i}=\"\"")).collect();
|
||||
let x = format!("<w:document {W_NS}><w:body><w:p{attrs}><w:r><w:t>kept</w:t></w:r></w:p></w:body></w:document>");
|
||||
let start = std::time::Instant::now();
|
||||
match read(&doc_zip(x.as_bytes())) {
|
||||
Err(DocxError::Limit(_)) => {}
|
||||
Ok(d) => assert!(d.plain_text(wordcraft_doc::StoryRef::Body).contains("kept")),
|
||||
Err(e) => panic!("expected a Limit error or a document, got {e}"),
|
||||
}
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(10), "took {:?}", start.elapsed());
|
||||
}
|
||||
|
||||
/// A small valid document.xml to mutate.
|
||||
fn base_xml() -> String {
|
||||
format!(
|
||||
@@ -258,6 +273,49 @@ proptest! {
|
||||
}
|
||||
}
|
||||
|
||||
/// The streams of a Standard-encrypted package (gap 9), and a compound file holding them.
|
||||
fn standard_streams() -> (Vec<u8>, Vec<u8>) {
|
||||
use std::io::Read;
|
||||
let file = wordcraft_docx::encrypt_standard_for_tests(&sample(), "pw", 128).unwrap();
|
||||
let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(file)).unwrap();
|
||||
let mut info = Vec::new();
|
||||
comp.open_stream("/EncryptionInfo").unwrap().read_to_end(&mut info).unwrap();
|
||||
let mut package = Vec::new();
|
||||
comp.open_stream("/EncryptedPackage").unwrap().read_to_end(&mut package).unwrap();
|
||||
(info, package)
|
||||
}
|
||||
|
||||
fn compound(info: &[u8], package: &[u8]) -> Vec<u8> {
|
||||
let mut comp = cfb::CompoundFile::create(std::io::Cursor::new(Vec::new())).unwrap();
|
||||
comp.create_stream("/EncryptionInfo").unwrap().write_all(info).unwrap();
|
||||
comp.create_stream("/EncryptedPackage").unwrap().write_all(package).unwrap();
|
||||
comp.flush().unwrap();
|
||||
comp.into_inner().into_inner()
|
||||
}
|
||||
|
||||
proptest! {
|
||||
// Each case that parses runs the 50,000-spin key derivation, so fewer cases.
|
||||
#![proptest_config(ProptestConfig { cases: 48, ..ProptestConfig::default() })]
|
||||
|
||||
#[test]
|
||||
fn mutated_standard_encryption_never_panics(ops in proptest::collection::vec((0u8..3, any::<prop::sample::Index>(), any::<u8>(), any::<bool>()), 1..8)) {
|
||||
let (mut info, mut package) = standard_streams();
|
||||
for (k, idx, byte, in_package) in ops {
|
||||
let x = if in_package { &mut package } else { &mut info };
|
||||
let at = idx.index(x.len() + 1);
|
||||
match k {
|
||||
0 => { if at < x.len() { x[at] = byte; } }
|
||||
1 => { x.truncate(at); }
|
||||
_ => { x.splice(at..at, std::iter::repeat_n(byte, usize::from(byte % 9))); }
|
||||
}
|
||||
}
|
||||
let file = compound(&info, &package);
|
||||
let _ = wordcraft_docx::decrypt(&file, Some("pw"));
|
||||
let _ = wordcraft_docx::read_with_password(&file, Some("pw"));
|
||||
prop_assert!(read(&file).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_header_reference_chain_is_cut_off() {
|
||||
const HDR_REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header";
|
||||
@@ -284,3 +342,38 @@ fn long_header_reference_chain_is_cut_off() {
|
||||
assert!(!d.parts.is_empty());
|
||||
assert!(d.parts.len() <= 8, "followed {} chained headers", d.parts.len());
|
||||
}
|
||||
|
||||
/// #3642: a compound file whose DIFAT names one FAT sector thousands of times is refused by the
|
||||
/// encrypted-package sniff (reached for any `.docx` name) before `cfb` builds its FAT.
|
||||
#[test]
|
||||
fn a_repeating_difat_is_refused_before_cfb_opens_it() {
|
||||
let f = wordcraft_doc::cfb_guard::repeating_difat_sample();
|
||||
assert!(!wordcraft_docx::is_encrypted(&f));
|
||||
assert!(matches!(wordcraft_docx::decrypt(&f, None), Err(e) if e.to_string().contains("FAT sectors in a file of")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_with_non_numeric_ids_open_in_linear_time() {
|
||||
// Each generated id was checked against every id given so far, so a comments part with many
|
||||
// ids that aren't numbers took time quadratic in their count (#3692).
|
||||
const RELS_NS: &str = "http://schemas.openxmlformats.org/package/2006/relationships";
|
||||
const COMMENTS_REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments";
|
||||
let n = 49_000;
|
||||
let mut comments = format!("<w:comments {W_NS}>");
|
||||
for i in 0..n {
|
||||
comments.push_str(&format!(r#"<w:comment w:id="c{i}" w:author="A"><w:p/></w:comment>"#));
|
||||
}
|
||||
comments.push_str("</w:comments>");
|
||||
let doc = format!("<w:document {W_NS}><w:body><w:p><w:r><w:t>body</w:t></w:r></w:p></w:body></w:document>");
|
||||
let rels = format!(r#"<Relationships xmlns="{RELS_NS}"><Relationship Id="rId1" Type="{COMMENTS_REL}" Target="comments.xml"/></Relationships>"#);
|
||||
let z = zip(&[
|
||||
("_rels/.rels", ROOT_RELS.as_bytes()),
|
||||
("word/document.xml", doc.as_bytes()),
|
||||
("word/_rels/document.xml.rels", rels.as_bytes()),
|
||||
("word/comments.xml", comments.as_bytes()),
|
||||
]);
|
||||
let d = read(&z).unwrap();
|
||||
assert_eq!(d.comments.len(), n);
|
||||
let ids: std::collections::HashSet<_> = d.comments.keys().collect();
|
||||
assert_eq!(ids.len(), n, "every comment has its own id");
|
||||
}
|
||||
|
||||
@@ -357,3 +357,49 @@ fn extended_properties_are_kept_without_words_counts_and_personal_ones_follow_th
|
||||
assert!(!private.contains("Company") && !private.contains("Manager"), "{private}");
|
||||
assert!(private.contains("<Template>Normal.dotm</Template>"));
|
||||
}
|
||||
|
||||
/// `name` with its letters' case flipped by the bits of `i`.
|
||||
fn spelling(name: &str, i: u32) -> String {
|
||||
let mut bit = 0;
|
||||
name.chars()
|
||||
.map(|c| {
|
||||
if !c.is_ascii_alphabetic() {
|
||||
return c;
|
||||
}
|
||||
bit += 1;
|
||||
if i >> (bit - 1) & 1 == 1 { c.to_ascii_uppercase() } else { c.to_ascii_lowercase() }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_part_reached_under_many_spellings_is_kept_and_written_once() {
|
||||
// Part names are case-insensitive (ECMA-376 Part 2 §6.2.2.3): 64 relationships naming one picture
|
||||
// and one unmodelled part in 64 casings are two parts, not 128 copies on save (#3704).
|
||||
let ns = r#"xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture""#;
|
||||
let n = 64;
|
||||
let mut body = String::new();
|
||||
let mut doc_rels = Vec::new();
|
||||
for i in 0..n {
|
||||
body.push_str(&format!(
|
||||
r#"<w:p><w:r><w:drawing><wp:inline {ns}><wp:extent cx="9525" cy="9525"/><wp:docPr id="{}" name="p"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:blipFill><a:blip r:embed="rIdP{i}"/></pic:blipFill></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"#,
|
||||
i + 1
|
||||
));
|
||||
doc_rels.push((format!("rIdP{i}"), format!("{REL}/image"), spelling("media/image1.png", i)));
|
||||
doc_rels.push((format!("rIdU{i}"), "urn:example:future-relationship".to_string(), spelling("unknownPart.xml", i)));
|
||||
}
|
||||
let doc_rels: Vec<(&str, &str, &str)> = doc_rels.iter().map(|(a, b, c)| (a.as_str(), b.as_str(), c.as_str())).collect();
|
||||
let picture = format!("\u{89}PNG\r\n\u{1a}\n{}", "0".repeat(64 * 1024));
|
||||
let unknown = format!(r#"<x:thing xmlns:x="urn:example:future">{}</x:thing>"#, "x".repeat(64 * 1024));
|
||||
let ct = CT.replace("<Default Extension=\"jpeg\"", "<Default Extension=\"png\" ContentType=\"image/png\"/><Default Extension=\"jpeg\"");
|
||||
let bytes =
|
||||
package(&body, &doc_rels, &[], &[("[Content_Types].xml", &ct), ("word/media/image1.png", &picture), ("word/unknownPart.xml", &unknown)]);
|
||||
let doc = wordcraft_docx::read(&bytes).unwrap();
|
||||
assert_eq!(doc.media.len(), 1, "{:?}", doc.media.keys());
|
||||
let files = save(&doc);
|
||||
let named = |s: &str| files.keys().filter(|k| k.to_ascii_lowercase().contains(s)).count();
|
||||
assert_eq!(named("unknownpart"), 1, "{:?}", files.keys());
|
||||
assert_eq!(files.keys().filter(|k| k.starts_with("word/media/")).count(), 1, "{:?}", files.keys());
|
||||
let written: usize = files.values().map(Vec::len).sum();
|
||||
assert!(written < 4 * 64 * 1024, "{written} bytes written");
|
||||
}
|
||||
|
||||
@@ -8,6 +8,12 @@ description = "WordCraft engine: session, selection, undo, the command registry
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
# Front-end producer tests need to simulate a globally exhausted IME revision allocator. It only
|
||||
# ever makes receipt classification fail closed. Never enable this for a production build; the
|
||||
# UI's dev-dependencies enable it for its tests.
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
wordcraft-geom = { workspace = true, features = ["image"] }
|
||||
wordcraft-doc = { workspace = true }
|
||||
@@ -36,6 +42,10 @@ js-sys = "0.3"
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
getrandom = { workspace = true }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
# O_NONBLOCK, so a path swapped for a named pipe can't block `open` (#3666).
|
||||
libc = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
encoding_rs = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
|
||||
@@ -313,6 +313,22 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The per-case shape/geometry of one scaled-group reroute case.
|
||||
struct ScaledGroupSpec {
|
||||
w: f32,
|
||||
h: f32,
|
||||
kind: ShapeKind,
|
||||
rot: f32,
|
||||
flip_h: bool,
|
||||
flip_v: bool,
|
||||
}
|
||||
|
||||
impl ScaledGroupSpec {
|
||||
fn new(w: f32, h: f32, kind: ShapeKind, rot: f32, flip_h: bool, flip_v: bool) -> Self {
|
||||
Self { w, h, kind, rot, flip_h, flip_v }
|
||||
}
|
||||
}
|
||||
|
||||
fn visual_ends(group: &InlineObject) -> ((f32, f32), (f32, f32)) {
|
||||
let InlineObject::Group { w, h, float, .. } = group else { panic!("group") };
|
||||
let rects = group.group_rects(0.0, 0.0, *w, *h);
|
||||
@@ -327,6 +343,61 @@ mod tests {
|
||||
(turn((x, y)), turn((x + w, y + h)))
|
||||
}
|
||||
|
||||
/// One scaled-group case: rerouting keeps the visual free end and the resolved sites.
|
||||
fn assert_scaled_group_reroute(spec: ScaledGroupSpec, free: Option<ConnEnd>, free_start: bool) {
|
||||
let attached = Some(ConnEnd { id: 7, site: 3 });
|
||||
let (start, end) = if free_start { (free, attached) } else { (attached, free) };
|
||||
let group = InlineObject::Group {
|
||||
w: spec.w,
|
||||
h: spec.h,
|
||||
ch_w: 200.0,
|
||||
ch_h: 300.0,
|
||||
float: Float { rot: 25.0, flip_h: true, ..Default::default() },
|
||||
canvas: None,
|
||||
children: vec![
|
||||
GroupChild { x: 100.0, y: 200.0, obj: shape(ShapeKind::Rectangle, ShapeExtra { id: 7, ..Default::default() }, Float::default()) },
|
||||
GroupChild {
|
||||
x: 10.0,
|
||||
y: 30.0,
|
||||
obj: shape(
|
||||
spec.kind,
|
||||
ShapeExtra { start, end, ..Default::default() },
|
||||
Float { rot: spec.rot, flip_h: spec.flip_h, flip_v: spec.flip_v, ..Default::default() },
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
// Rendering scales each child rectangle before applying its own spin.
|
||||
let before = visual_ends(&group);
|
||||
let expected = if free_start { before.0 } else { before.1 };
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
let pos = s.doc.start_of(StoryRef::Body);
|
||||
s.doc.para_mut(pos.story, &pos.path).unwrap().insert_object(pos.off, group, &Default::default()).unwrap();
|
||||
assert!(reroute(&mut s));
|
||||
let group = s.doc.para_at(&pos).unwrap().object_at(pos.off).unwrap();
|
||||
let after = visual_ends(group);
|
||||
let actual = if free_start { after.0 } else { after.1 };
|
||||
assert!(
|
||||
(actual.0 - expected.0).abs() < 0.001 && (actual.1 - expected.1).abs() < 0.001,
|
||||
"{}x{}, {:?}, {}, {}, {}, {}: {:?} != {:?}",
|
||||
spec.w,
|
||||
spec.h,
|
||||
spec.kind,
|
||||
spec.rot,
|
||||
spec.flip_h,
|
||||
spec.flip_v,
|
||||
free_start,
|
||||
actual,
|
||||
expected
|
||||
);
|
||||
let InlineObject::Group { children, .. } = group else { panic!("group") };
|
||||
let c = &children[1];
|
||||
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!("connector") };
|
||||
let routed = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v);
|
||||
assert_eq!(if free_start { routed.1 } else { routed.0 }, (140.0, 210.0), "resolved sites stay in child coordinates");
|
||||
assert_eq!(float.rot, 0.0);
|
||||
assert!(!reroute(&mut s), "rerouting must settle");
|
||||
}
|
||||
#[test]
|
||||
fn rerouting_scaled_group_preserves_visual_free_and_unresolved_ends() {
|
||||
for (w, h) in [(200.0, 300.0), (400.0, 600.0), (400.0, 150.0)] {
|
||||
@@ -335,53 +406,7 @@ mod tests {
|
||||
for (flip_h, flip_v) in [(false, false), (true, false), (false, true), (true, true)] {
|
||||
for free in [None, Some(ConnEnd { id: 404, site: 0 }), Some(ConnEnd { id: 7, site: 99 })] {
|
||||
for free_start in [false, true] {
|
||||
let attached = Some(ConnEnd { id: 7, site: 3 });
|
||||
let (start, end) = if free_start { (free, attached) } else { (attached, free) };
|
||||
let group = InlineObject::Group {
|
||||
w,
|
||||
h,
|
||||
ch_w: 200.0,
|
||||
ch_h: 300.0,
|
||||
float: Float { rot: 25.0, flip_h: true, ..Default::default() },
|
||||
canvas: None,
|
||||
children: vec![
|
||||
GroupChild {
|
||||
x: 100.0,
|
||||
y: 200.0,
|
||||
obj: shape(ShapeKind::Rectangle, ShapeExtra { id: 7, ..Default::default() }, Float::default()),
|
||||
},
|
||||
GroupChild {
|
||||
x: 10.0,
|
||||
y: 30.0,
|
||||
obj: shape(
|
||||
kind,
|
||||
ShapeExtra { start, end, ..Default::default() },
|
||||
Float { rot, flip_h, flip_v, ..Default::default() },
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
// Rendering scales each child rectangle before applying its own spin.
|
||||
let before = visual_ends(&group);
|
||||
let expected = if free_start { before.0 } else { before.1 };
|
||||
let mut s = Session::new(wordcraft_doc::Document::new());
|
||||
let pos = s.doc.start_of(StoryRef::Body);
|
||||
s.doc.para_mut(pos.story, &pos.path).unwrap().insert_object(pos.off, group, &Default::default()).unwrap();
|
||||
assert!(reroute(&mut s));
|
||||
let group = s.doc.para_at(&pos).unwrap().object_at(pos.off).unwrap();
|
||||
let after = visual_ends(group);
|
||||
let actual = if free_start { after.0 } else { after.1 };
|
||||
assert!(
|
||||
(actual.0 - expected.0).abs() < 0.001 && (actual.1 - expected.1).abs() < 0.001,
|
||||
"{w}x{h}, {kind:?}, {rot}, {flip_h}, {flip_v}, {free_start}: {actual:?} != {expected:?}"
|
||||
);
|
||||
let InlineObject::Group { children, .. } = group else { panic!("group") };
|
||||
let c = &children[1];
|
||||
let InlineObject::Shape { w, h, float, .. } = &c.obj else { panic!("connector") };
|
||||
let routed = ends(c.x, c.y, *w, *h, float.flip_h, float.flip_v);
|
||||
assert_eq!(if free_start { routed.1 } else { routed.0 }, (140.0, 210.0), "resolved sites stay in child coordinates");
|
||||
assert_eq!(float.rot, 0.0);
|
||||
assert!(!reroute(&mut s), "rerouting must settle");
|
||||
assert_scaled_group_reroute(ScaledGroupSpec::new(w, h, kind, rot, flip_h, flip_v), free, free_start);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Design tab: themes, style sets, colours, fonts, paragraph spacing, watermark, page colour, borders.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::props::{Border, BorderStyle, Borders, LineSpacing, Rgb, TextColor};
|
||||
use wordcraft_doc::props::{Border, BorderStyle, Borders, CharProps, LineSpacing, Rgb, TextColor};
|
||||
use wordcraft_doc::{Watermark, styles};
|
||||
|
||||
use super::sel_result;
|
||||
@@ -67,6 +67,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.params(r#"{"name": "default|basic|lines|shaded|casual|centered|minimalist|title"}"#),
|
||||
CommandSpec::new("design.paragraphSpacing", "Paragraph Spacing", "Design › Document Formatting", para_spacing)
|
||||
.params(r#"{"value": "default|none|compact|tight|open|relaxed|double"}"#),
|
||||
CommandSpec::new("design.scaleFontSizes", "Scale Font Sizes", "Design › Document Formatting", scale_font_sizes)
|
||||
.params(r#"{"delta": pt (-1638..1638, not 0) | "percent": number (-99..1000, not 0; 10 = +10%)} (exactly one)"#),
|
||||
CommandSpec::new("design.watermark", "Watermark", "Design › Page Background", watermark)
|
||||
.params(r#"{"text"?: string, "remove"?: bool, "diagonal"?: bool, "color"?: "RRGGBB"}"#),
|
||||
CommandSpec::new("design.pageColor", "Page Color", "Design › Page Background", |s, v| {
|
||||
@@ -169,11 +171,19 @@ fn style_set(s: &mut Session, v: &Value) -> CmdResult {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Keep custom styles and fonts.
|
||||
let customs: Vec<_> = s.doc.styles.styles.iter().filter(|x| !x.builtin).cloned().collect();
|
||||
fresh.styles.extend(customs);
|
||||
fresh.default_chr.font = s.doc.styles.default_chr.font.clone();
|
||||
s.doc.styles = fresh;
|
||||
// Overlay the set onto the document's sheet: every other style, the latent styles and the
|
||||
// document defaults stay as they are (no set defines its own defaults).
|
||||
for mut st in fresh.styles {
|
||||
match s.doc.styles.styles.iter_mut().find(|x| x.id == st.id) {
|
||||
Some(old) => {
|
||||
st.default = old.default;
|
||||
st.locked = old.locked;
|
||||
st.builtin = old.builtin;
|
||||
*old = st;
|
||||
}
|
||||
None => s.doc.styles.styles.push(st),
|
||||
}
|
||||
}
|
||||
let (h, b) = (s.doc.settings.major_font.clone(), s.doc.settings.minor_font.clone());
|
||||
set_fonts(s, &h, &b);
|
||||
set_accent(s, accent);
|
||||
@@ -196,6 +206,45 @@ fn para_spacing(s: &mut Session, v: &Value) -> CmdResult {
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Grow or shrink every style's font size at once. Only explicit style sizes change (including
|
||||
/// table style parts); styles that inherit one (via `based_on` or the defaults) follow, and runs
|
||||
/// keep their own direct formatting.
|
||||
fn scale_font_sizes(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let (delta, percent) = (p::f32(v, "delta"), p::f32(v, "percent"));
|
||||
// A field that is present but not a number is an error, not "absent".
|
||||
if (v.get("delta").is_some() && delta.is_none()) || (v.get("percent").is_some() && percent.is_none()) {
|
||||
return Err(CmdError::Params("`delta` and `percent` must be numbers".into()));
|
||||
}
|
||||
if delta.is_some() == percent.is_some() {
|
||||
return Err(CmdError::Params("give exactly one of `delta` (points) or `percent`".into()));
|
||||
}
|
||||
if delta.is_some_and(|d| !(-1638.0..=1638.0).contains(&d) || d == 0.0) || percent.is_some_and(|x| !(-99.0..=1000.0).contains(&x) || x == 0.0) {
|
||||
return Err(CmdError::Params("`delta` must be non-zero within ±1638 pt and `percent` non-zero within -99..1000".into()));
|
||||
}
|
||||
let factor = 1.0 + percent.unwrap_or(0.0) / 100.0;
|
||||
let scale = |size: f32| ((size * factor + delta.unwrap_or(0.0)).clamp(1.0, 1638.0) * 2.0).round() / 2.0;
|
||||
let scale_chr = |c: &mut CharProps| {
|
||||
c.size = c.size.map(scale);
|
||||
c.size_cs = c.size_cs.map(scale);
|
||||
};
|
||||
let st = &mut s.doc.styles;
|
||||
// An unset document default resolves to the fallback size; make it explicit so inheriting styles follow.
|
||||
st.default_chr.size = Some(scale(st.default_chr.size.unwrap_or(wordcraft_doc::props::DEFAULT_FONT_SIZE)));
|
||||
st.default_chr.size_cs = st.default_chr.size_cs.map(scale);
|
||||
for style in &mut st.styles {
|
||||
scale_chr(&mut style.chr);
|
||||
if let Some(t) = style.table.as_mut() {
|
||||
for c in [&mut t.header_chr, &mut t.band_chr, &mut t.first_col_chr, &mut t.last_col_chr, &mut t.total_chr, &mut t.col_band_chr] {
|
||||
scale_chr(c);
|
||||
}
|
||||
for part in t.regions.values_mut() {
|
||||
scale_chr(&mut part.chr);
|
||||
}
|
||||
}
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
fn watermark(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if p::bool(v, "remove").unwrap_or(false) {
|
||||
s.doc.settings.watermark = None;
|
||||
|
||||
@@ -142,7 +142,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.params(r#"{"tab"?: "find|replace|goto" (default replace)}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("edit.replaceAll", "Replace All", "Home › Editing › Replace", replace_all)
|
||||
.params(r#"{"text": string, "with": string (^p ^t ^& ^c …; \\1 with wildcards), …options of edit.find}"#),
|
||||
.params(r#"{"text": string, "with": string (^p ^t ^& ^c …; \\1 with wildcards; with regex also $1 ${n} ${name} $0 $$ \\1 \\n (paragraph mark) \\t), …options of edit.find}"#),
|
||||
CommandSpec::new("edit.goto", "Go To", "Home › Editing › Find", goto)
|
||||
.key("Mod+Alt+G / F5")
|
||||
.params(r#"{"page"?: n, "pageBy"?: int (pages forward, negative back, from the caret's page), "bookmark"?: string (selects its text), "paragraph"?: n}; none opens Find and Replace on its Go To tab"#)
|
||||
@@ -459,7 +459,10 @@ pub(crate) fn search_full(s: &Session, story: StoryRef, with_rep: bool) -> Resul
|
||||
.configure(meta::Config::new().nfa_size_limit(Some(1 << 20)))
|
||||
.build(&c.pattern)
|
||||
.map_err(|e| CmdError::Params(format!("bad pattern: {e}")))?;
|
||||
let pieces = with_rep.then(|| find_pattern::parse_replace(&f.replace, &opts));
|
||||
let pieces = with_rep.then(|| {
|
||||
let index = |name: &str| re.group_info().to_index(regex_automata::PatternID::ZERO, name);
|
||||
find_pattern::resolve_names(find_pattern::parse_replace(&f.replace, &opts), &index)
|
||||
});
|
||||
let paths = s.doc.para_paths(story);
|
||||
// Paragraphs read as one text: consecutive siblings when the pattern spans paragraphs.
|
||||
let mut chunks: Vec<std::ops::Range<usize>> = Vec::new();
|
||||
|
||||
@@ -10,7 +10,7 @@ use wordcraft_doc::para::OBJ;
|
||||
use wordcraft_doc::{InlineObject, Pos};
|
||||
|
||||
use super::{delete_selection, sel_result, split_para};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, MathEdit, Selection, Session, p};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, ImeEditOutcome, MathEdit, Selection, Session, p};
|
||||
|
||||
fn editing(s: &Session) -> Option<&'static str> {
|
||||
if s.math.is_none() { Some("not editing an equation") } else { None }
|
||||
@@ -20,6 +20,9 @@ fn near_equation(s: &Session) -> Option<&'static str> {
|
||||
if s.math.is_some() || equation_at_caret(s).is_some() { None } else { Some("no equation at the caret") }
|
||||
}
|
||||
|
||||
/// Builds the equation command table: insert, edit and exit, typing, structures, symbols, caret
|
||||
/// movement, deletion (including bounded surrounding deletion), enter, conversion, input format,
|
||||
/// normal text, display, justification, numbering, set and get, gallery and structure actions.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("insert.equation", "Equation", "Insert › Symbols", insert)
|
||||
@@ -40,6 +43,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.params(r#"{"dir": "left|right|up|down|home|end|next|prev"}"#),
|
||||
CommandSpec::new("equation.backspace", "Delete Back in Equation", "Equation", |s, _| delete(s, true)).when(editing),
|
||||
CommandSpec::new("equation.delete", "Delete in Equation", "Equation", |s, _| delete(s, false)).when(editing),
|
||||
CommandSpec::new("equation.deleteSurrounding", "Delete Around Caret in Equation", "Equation", delete_surrounding)
|
||||
.when(editing)
|
||||
.params(r#"{"before": n, "after": n}"#),
|
||||
CommandSpec::new("equation.enter", "Enter in Equation", "Equation", enter).when(editing),
|
||||
CommandSpec::new("equation.convert", "Convert", "Equation › Conversions", convert)
|
||||
.when(near_equation_or_all)
|
||||
@@ -100,21 +106,73 @@ fn equation_at_caret(s: &Session) -> Option<Pos> {
|
||||
None
|
||||
}
|
||||
|
||||
/// What one equation edit changed. `content_changed` gates an IME receipt; `state_changed`
|
||||
/// covers style, representation and lazy-normalization changes that must poison a mixed frame.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct EquationEffect {
|
||||
content_changed: bool,
|
||||
state_changed: bool,
|
||||
}
|
||||
|
||||
impl EquationEffect {
|
||||
/// Merges another edit's effect flags, so a change on either side marks the merged effect.
|
||||
fn merge(&mut self, other: Self) {
|
||||
self.content_changed |= other.content_changed;
|
||||
self.state_changed |= other.state_changed;
|
||||
}
|
||||
}
|
||||
|
||||
/// Records the edit as a classified IME outcome; any content change also counts as a state
|
||||
/// change, and `transformed` marks automatic rewrites.
|
||||
fn apply_effect(s: &mut Session, effect: EquationEffect, transformed: bool) {
|
||||
s.set_ime_edit_outcome(ImeEditOutcome {
|
||||
classified: true,
|
||||
content_changed: effect.content_changed,
|
||||
state_changed: effect.state_changed || effect.content_changed,
|
||||
transformed,
|
||||
selection_changed: false,
|
||||
});
|
||||
}
|
||||
|
||||
/// Records a classified IME outcome with no content or state change, distinguishing a real
|
||||
/// no-op from a command that never classified its edit.
|
||||
fn classified_noop(s: &mut Session) {
|
||||
s.set_ime_edit_outcome(ImeEditOutcome { classified: true, ..ImeEditOutcome::default() });
|
||||
}
|
||||
|
||||
/// Run `f` on the equation at `at`; keeps its linear text in step and drops the source OMML
|
||||
/// (it no longer matches).
|
||||
fn with_eq<R>(s: &mut Session, at: &Pos, f: impl FnOnce(&mut Math, &mut bool) -> R) -> Result<R, CmdError> {
|
||||
///
|
||||
/// The logical before-state is captured *after* lazy `nodes` materialization: an imported
|
||||
/// equation that only gets normalized is a state change, never an IME content change.
|
||||
fn with_eq<R>(s: &mut Session, at: &Pos, f: impl FnOnce(&mut Math, &mut bool) -> R) -> Result<(R, EquationEffect), CmdError> {
|
||||
let para = s.doc.para_mut(at.story, &at.path)?;
|
||||
let Some(InlineObject::Equation { linear, display, math }) = para.object_at_mut(at.off) else {
|
||||
return Err(CmdError::Failed("no equation there".into()));
|
||||
};
|
||||
if math.nodes.is_empty() && !linear.is_empty() && !math.linear {
|
||||
let normalized = math.nodes.is_empty() && !linear.is_empty() && !math.linear;
|
||||
if normalized {
|
||||
math.nodes = parse_linear(linear);
|
||||
}
|
||||
let nodes_before = math.nodes.clone();
|
||||
let linear_mode_before = math.linear;
|
||||
let jc_before = math.jc;
|
||||
let display_before = *display;
|
||||
let cached_linear_before = linear.clone();
|
||||
let omml_before_nonempty = !math.omml.is_empty();
|
||||
let r = f(math, display);
|
||||
*linear = if math.linear { plain(&math.nodes) } else { to_linear(&math.nodes) };
|
||||
let new_linear = if math.linear { plain(&math.nodes) } else { to_linear(&math.nodes) };
|
||||
let content_changed = math.nodes != nodes_before || math.linear != linear_mode_before;
|
||||
let state_changed = normalized
|
||||
|| content_changed
|
||||
|| math.jc != jc_before
|
||||
|| *display != display_before
|
||||
|| omml_before_nonempty
|
||||
|| new_linear != cached_linear_before;
|
||||
*linear = new_linear;
|
||||
math.omml.clear();
|
||||
para.touch();
|
||||
Ok(r)
|
||||
Ok((r, EquationEffect { content_changed, state_changed }))
|
||||
}
|
||||
|
||||
/// All the text of an equation's runs (its linear form while shown linear).
|
||||
@@ -163,6 +221,9 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
|
||||
insert_nodes_as_equation(s, nodes, v)
|
||||
}
|
||||
|
||||
/// Clears any editing session, deletes the selection, creates an equation object (display on an
|
||||
/// empty line unless the request overrides it), optionally opens editing at the first
|
||||
/// placeholder, and reports a content change.
|
||||
fn insert_nodes_as_equation(s: &mut Session, nodes: Arg, v: &Value) -> CmdResult {
|
||||
s.math = None;
|
||||
let props = s.typing_props();
|
||||
@@ -178,14 +239,27 @@ fn insert_nodes_as_equation(s: &mut Session, nodes: Arg, v: &Value) -> CmdResult
|
||||
let obj = InlineObject::Equation { linear, display, math: Math { nodes, ..Default::default() } };
|
||||
let end = s.doc.insert_object(&at, obj, &props)?;
|
||||
if p::bool(v, "edit").unwrap_or(true) {
|
||||
let Some(session) = crate::next_math_session() else {
|
||||
return Err(CmdError::Failed("no equation editing session available".into()));
|
||||
};
|
||||
s.sel = Selection::caret(at.clone());
|
||||
s.math = Some(MathEdit { at, pos: caret });
|
||||
s.math = Some(MathEdit { at, pos: caret, session });
|
||||
} else {
|
||||
s.sel = Selection::caret(end);
|
||||
}
|
||||
// Creating an equation inserts visible content, even when its tree is initially empty.
|
||||
s.set_ime_edit_outcome(ImeEditOutcome {
|
||||
classified: true,
|
||||
content_changed: true,
|
||||
state_changed: true,
|
||||
transformed: false,
|
||||
selection_changed: false,
|
||||
});
|
||||
result(s)
|
||||
}
|
||||
|
||||
/// Starts editing the equation at the caret, materializing lazy nodes and clamping the requested
|
||||
/// position, and returns the equation and caret.
|
||||
fn edit(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?;
|
||||
let nodes = match s.doc.para_at(&at).and_then(|p| p.object_at(at.off)) {
|
||||
@@ -200,8 +274,11 @@ fn edit(s: &mut Session, v: &Value) -> CmdResult {
|
||||
};
|
||||
let pos =
|
||||
v.get("pos").and_then(|p| serde_json::from_value::<MathPos>(p.clone()).ok()).unwrap_or_else(|| MathPos::new(Vec::new(), me::units(&nodes)));
|
||||
let Some(session) = crate::next_math_session() else {
|
||||
return Err(CmdError::Failed("no equation editing session available".into()));
|
||||
};
|
||||
s.sel = Selection::caret(at.clone());
|
||||
s.math = Some(MathEdit { pos: me::clamp(&nodes, &pos), at });
|
||||
s.math = Some(MathEdit { pos: me::clamp(&nodes, &pos), at, session });
|
||||
result(s)
|
||||
}
|
||||
|
||||
@@ -218,20 +295,30 @@ fn current(s: &Session) -> Result<MathEdit, CmdError> {
|
||||
s.math.clone().ok_or_else(|| CmdError::Disabled("not editing an equation".into()))
|
||||
}
|
||||
|
||||
/// Caps typing at `MAX_EQUATION_TYPE_CHARS`; empty text is a classified no-op. Linear form
|
||||
/// inserts literal runs, while built-up form applies AutoCorrect and build-up before spaces and
|
||||
/// relational characters.
|
||||
fn type_text(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let text = p::req_str(v, "text")?.to_string();
|
||||
let m = current(s)?;
|
||||
if text.is_empty() {
|
||||
// No normalization, no linear-cache rewrite, no revision: an empty commit is a no-op.
|
||||
classified_noop(s);
|
||||
return result(s);
|
||||
}
|
||||
let latex = s.math_latex;
|
||||
let style = TypeStyle { nor: s.math_normal_text };
|
||||
let mut pos = m.pos.clone();
|
||||
with_eq(s, &m.at, |math, _| {
|
||||
let mut transformed = false;
|
||||
let (_, effect) = with_eq(s, &m.at, |math, _| {
|
||||
if math.linear {
|
||||
// Linear form is plain text: no build-up.
|
||||
let text: String = text.chars().take(crate::MAX_EQUATION_TYPE_CHARS).collect();
|
||||
pos = me::insert_text(&mut math.nodes, &pos, &text, TypeStyle::default());
|
||||
set_literal(&mut math.nodes);
|
||||
return;
|
||||
}
|
||||
for c in text.chars().take(10_000) {
|
||||
for c in text.chars().take(crate::MAX_EQUATION_TYPE_CHARS) {
|
||||
let rel = matches!(wordcraft_doc::math::math_class(c), wordcraft_doc::math::MClass::Rel);
|
||||
if !style.nor && (c == ' ' || c == '\t' || rel) {
|
||||
let mut changed = false;
|
||||
@@ -243,6 +330,9 @@ fn type_text(s: &mut Session, v: &Value) -> CmdResult {
|
||||
pos = p;
|
||||
changed = true;
|
||||
}
|
||||
if changed {
|
||||
transformed = true;
|
||||
}
|
||||
if c == ' ' && changed {
|
||||
continue;
|
||||
}
|
||||
@@ -253,9 +343,9 @@ fn type_text(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if let Some(m) = s.math.as_mut() {
|
||||
m.pos = pos;
|
||||
}
|
||||
apply_effect(s, effect, transformed);
|
||||
result(s)
|
||||
}
|
||||
|
||||
/// Runs of a linear-form equation are literal text.
|
||||
fn set_literal(nodes: &mut Arg) {
|
||||
for n in nodes.iter_mut() {
|
||||
@@ -265,6 +355,8 @@ fn set_literal(nodes: &mut Arg) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts a gallery, linear or LaTeX structure: a new equation when none is being edited,
|
||||
/// literal text in linear form, or nodes at the caret in built-up form.
|
||||
fn insert_structure(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let nodes = nodes_from(v).ok_or_else(|| CmdError::Params("give an id, linear or latex".into()))?;
|
||||
let Some(m) = s.math.clone() else {
|
||||
@@ -272,7 +364,7 @@ fn insert_structure(s: &mut Session, v: &Value) -> CmdResult {
|
||||
return insert_nodes_as_equation(s, nodes, &json!({}));
|
||||
};
|
||||
let mut pos = m.pos.clone();
|
||||
with_eq(s, &m.at, |math, _| {
|
||||
let (_, effect) = with_eq(s, &m.at, |math, _| {
|
||||
if math.linear {
|
||||
pos = me::insert_text(&mut math.nodes, &pos, &to_linear(&nodes), TypeStyle::default());
|
||||
set_literal(&mut math.nodes);
|
||||
@@ -283,9 +375,12 @@ fn insert_structure(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if let Some(m) = s.math.as_mut() {
|
||||
m.pos = pos;
|
||||
}
|
||||
apply_effect(s, effect, false);
|
||||
result(s)
|
||||
}
|
||||
|
||||
/// Rejects an empty character; outside editing creates an equation, otherwise inserts the
|
||||
/// character at the math caret, marked literal in linear form.
|
||||
fn insert_symbol(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let c = p::req_str(v, "char")?.to_string();
|
||||
if c.is_empty() {
|
||||
@@ -296,7 +391,7 @@ fn insert_symbol(s: &mut Session, v: &Value) -> CmdResult {
|
||||
};
|
||||
let mut pos = m.pos.clone();
|
||||
let style = TypeStyle { nor: s.math_normal_text };
|
||||
with_eq(s, &m.at, |math, _| {
|
||||
let (_, effect) = with_eq(s, &m.at, |math, _| {
|
||||
pos = me::insert_text(&mut math.nodes, &pos, &c, style);
|
||||
if math.linear {
|
||||
set_literal(&mut math.nodes);
|
||||
@@ -305,6 +400,7 @@ fn insert_symbol(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if let Some(m) = s.math.as_mut() {
|
||||
m.pos = pos;
|
||||
}
|
||||
apply_effect(s, effect, false);
|
||||
result(s)
|
||||
}
|
||||
|
||||
@@ -317,6 +413,8 @@ fn nodes_of(s: &Session, at: &Pos) -> Option<Arg> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates the direction, commits an AutoCorrect or build-up triggered by leaving a slot as one
|
||||
/// undoable edit, moves or clamps the math caret, and exits past either end.
|
||||
fn move_caret(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let m = current(s)?;
|
||||
let dir = p::str(v, "dir").unwrap_or("right");
|
||||
@@ -324,48 +422,49 @@ fn move_caret(s: &mut Session, v: &Value) -> CmdResult {
|
||||
return Err(CmdError::Params(format!("unknown direction {dir}")));
|
||||
}
|
||||
// Leaving a slot finishes what was typed there (AutoCorrect names, build-up). That is worked
|
||||
// out on a copy: plain navigation must leave the equation (and its source OMML) untouched,
|
||||
// and a real change is a document edit — undoable and marking the document dirty (#446, #447).
|
||||
// out on a local copy: plain navigation must leave the equation (and its source OMML)
|
||||
// untouched, and a real change is a document edit — undoable and marking the document dirty
|
||||
// (#446, #447). Any failure happens before the single commit, so no rollback is needed.
|
||||
let shown_linear = matches!(
|
||||
s.doc.para_at(&m.at).and_then(|p| p.object_at(m.at.off)),
|
||||
Some(InlineObject::Equation { math, .. }) if math.linear
|
||||
);
|
||||
if !s.math_normal_text
|
||||
&& !shown_linear
|
||||
&& let Some(before) = nodes_of(s, &m.at)
|
||||
{
|
||||
let mut nodes = nodes_of(s, &m.at).ok_or_else(|| CmdError::Failed("no equation there".into()))?;
|
||||
let mut pos = m.pos.clone();
|
||||
if !s.math_normal_text && !shown_linear {
|
||||
let latex = s.math_latex;
|
||||
let mut nodes = before.clone();
|
||||
let mut pos = m.pos.clone();
|
||||
let before = nodes.clone();
|
||||
let mut candidate = nodes.clone();
|
||||
let mut candidate_pos = pos.clone();
|
||||
let mut changed = false;
|
||||
if let Some(p) = me::autocorrect(&mut nodes, &pos) {
|
||||
pos = p;
|
||||
if let Some(p) = me::autocorrect(&mut candidate, &candidate_pos) {
|
||||
candidate_pos = p;
|
||||
changed = true;
|
||||
}
|
||||
if let Some(p) = me::build_up(&mut nodes, &pos, latex) {
|
||||
pos = p;
|
||||
if let Some(p) = me::build_up(&mut candidate, &candidate_pos, latex) {
|
||||
candidate_pos = p;
|
||||
changed = true;
|
||||
}
|
||||
if changed && nodes != before {
|
||||
pos = candidate_pos;
|
||||
if changed && candidate != before {
|
||||
// `equation.move` is a pure command, so record the edit here.
|
||||
s.checkpoint("Build Up");
|
||||
with_eq(s, &m.at, |math, _| math.nodes = nodes)?;
|
||||
let (_, effect) = with_eq(s, &m.at, |math, _| math.nodes = candidate.clone())?;
|
||||
apply_effect(s, effect, true);
|
||||
// `equation.move` is pure for ordinary navigation, so `Session::run` does not touch
|
||||
// the document for us. A build-up is a real edit: mark it dirty and refresh layout.
|
||||
s.touch();
|
||||
if let Some(mm) = s.math.as_mut() {
|
||||
mm.pos = pos;
|
||||
}
|
||||
nodes = candidate;
|
||||
}
|
||||
}
|
||||
let m = current(s)?;
|
||||
let nodes = nodes_of(s, &m.at).ok_or_else(|| CmdError::Failed("no equation there".into()))?;
|
||||
let next = match dir {
|
||||
"left" => me::move_left(&nodes, &m.pos),
|
||||
"right" => me::move_right(&nodes, &m.pos),
|
||||
"up" => Some(me::move_vertical(&nodes, &m.pos, true).unwrap_or(m.pos.clone())),
|
||||
"down" => Some(me::move_vertical(&nodes, &m.pos, false).unwrap_or(m.pos.clone())),
|
||||
"home" => Some(me::home_end(&nodes, &m.pos, false)),
|
||||
"end" => Some(me::home_end(&nodes, &m.pos, true)),
|
||||
"next" | "prev" => Some(me::next_placeholder(&nodes, &m.pos, dir == "prev").unwrap_or(m.pos.clone())),
|
||||
"left" => me::move_left(&nodes, &pos),
|
||||
"right" => me::move_right(&nodes, &pos),
|
||||
"up" => Some(me::move_vertical(&nodes, &pos, true).unwrap_or(pos.clone())),
|
||||
"down" => Some(me::move_vertical(&nodes, &pos, false).unwrap_or(pos.clone())),
|
||||
"home" => Some(me::home_end(&nodes, &pos, false)),
|
||||
"end" => Some(me::home_end(&nodes, &pos, true)),
|
||||
"next" | "prev" => Some(me::next_placeholder(&nodes, &pos, dir == "prev").unwrap_or(pos.clone())),
|
||||
other => return Err(CmdError::Params(format!("unknown direction {other}"))),
|
||||
};
|
||||
match next {
|
||||
@@ -379,34 +478,96 @@ fn move_caret(s: &mut Session, v: &Value) -> CmdResult {
|
||||
None => exit(s, &json!({"before": dir == "left"})),
|
||||
}
|
||||
}
|
||||
|
||||
/// With the caret in an empty equation, removes the whole equation as a content change;
|
||||
/// otherwise deletes backward or forward, updates the caret and records the effect.
|
||||
fn delete(s: &mut Session, back: bool) -> CmdResult {
|
||||
let m = current(s)?;
|
||||
let nodes = nodes_of(s, &m.at).ok_or_else(|| CmdError::Failed("no equation there".into()))?;
|
||||
// An empty equation goes away entirely.
|
||||
// An empty equation goes away entirely: removing the object is a content change.
|
||||
if nodes.is_empty() {
|
||||
s.math = None;
|
||||
s.sel = Selection { anchor: m.at.clone(), focus: Pos { off: m.at.off + OBJ.len_utf8(), ..m.at.clone() } };
|
||||
let at = delete_selection(s)?;
|
||||
s.sel = Selection::caret(at);
|
||||
s.set_ime_edit_outcome(ImeEditOutcome {
|
||||
classified: true,
|
||||
content_changed: true,
|
||||
state_changed: true,
|
||||
transformed: false,
|
||||
selection_changed: false,
|
||||
});
|
||||
return sel_result(s);
|
||||
}
|
||||
let mut pos = m.pos.clone();
|
||||
with_eq(s, &m.at, |math, _| {
|
||||
let (_, effect) = with_eq(s, &m.at, |math, _| {
|
||||
pos = if back { me::delete_back(&mut math.nodes, &pos) } else { me::delete_forward(&mut math.nodes, &pos) };
|
||||
})?;
|
||||
if let Some(m) = s.math.as_mut() {
|
||||
m.pos = pos;
|
||||
}
|
||||
apply_effect(s, effect, false);
|
||||
result(s)
|
||||
}
|
||||
|
||||
/// Delete a bounded number of equation units on each side of the caret in one transaction.
|
||||
///
|
||||
/// The requested steps are simulated on a local tree and caret. Any step that cannot make
|
||||
/// progress refuses the whole request before the document is touched; the commit (if any) is a
|
||||
/// single command and a single undo step.
|
||||
fn delete_surrounding(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let m = current(s)?;
|
||||
let before = p::u64(v, "before").and_then(|n| usize::try_from(n).ok()).unwrap_or(usize::MAX);
|
||||
let after = p::u64(v, "after").and_then(|n| usize::try_from(n).ok()).unwrap_or(usize::MAX);
|
||||
if before.saturating_add(after) == 0 {
|
||||
return Err(CmdError::Params("nothing to delete".into()));
|
||||
}
|
||||
if before > crate::MAX_EQUATION_DELETE_UNITS || after > crate::MAX_EQUATION_DELETE_UNITS {
|
||||
return Err(CmdError::Params(format!("at most {} units on each side", crate::MAX_EQUATION_DELETE_UNITS)));
|
||||
}
|
||||
let mut nodes = nodes_of(s, &m.at).ok_or_else(|| CmdError::Failed("no equation there".into()))?;
|
||||
let mut pos = m.pos.clone();
|
||||
for _ in 0..before {
|
||||
let prev_nodes = nodes.clone();
|
||||
let prev_pos = pos.clone();
|
||||
pos = me::delete_back(&mut nodes, &pos);
|
||||
if nodes == prev_nodes {
|
||||
// Walking into the next argument is not a deletion. A requested unit that only
|
||||
// moves the caret must refuse the whole request, or a later step can apply against
|
||||
// a tree the platform's surrounding snapshot did not describe.
|
||||
return Err(CmdError::Failed(if pos == prev_pos {
|
||||
"the equation cannot delete that many units".into()
|
||||
} else {
|
||||
"the equation delete made no progress".into()
|
||||
}));
|
||||
}
|
||||
}
|
||||
for _ in 0..after {
|
||||
let prev_nodes = nodes.clone();
|
||||
let prev_pos = pos.clone();
|
||||
pos = me::delete_forward(&mut nodes, &pos);
|
||||
if nodes == prev_nodes {
|
||||
return Err(CmdError::Failed(if pos == prev_pos {
|
||||
"the equation cannot delete that many units".into()
|
||||
} else {
|
||||
"the equation delete made no progress".into()
|
||||
}));
|
||||
}
|
||||
}
|
||||
let (_, effect) = with_eq(s, &m.at, |math, _| math.nodes = nodes)?;
|
||||
if let Some(m) = s.math.as_mut() {
|
||||
m.pos = pos;
|
||||
}
|
||||
apply_effect(s, effect, false);
|
||||
Ok(json!({"before": before, "after": after, "changed": true}))
|
||||
}
|
||||
/// In built-up form runs AutoCorrect, build-up and equation numbering; when nothing changes,
|
||||
/// leaves the equation and splits a new paragraph.
|
||||
fn enter(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let m = current(s)?;
|
||||
let latex = s.math_latex;
|
||||
let mut pos = m.pos.clone();
|
||||
let mut changed = false;
|
||||
with_eq(s, &m.at, |math, _| {
|
||||
let (_, effect) = with_eq(s, &m.at, |math, _| {
|
||||
if math.linear {
|
||||
return;
|
||||
}
|
||||
@@ -427,6 +588,7 @@ fn enter(s: &mut Session, _: &Value) -> CmdResult {
|
||||
if let Some(m) = s.math.as_mut() {
|
||||
m.pos = pos;
|
||||
}
|
||||
apply_effect(s, effect, true);
|
||||
return result(s);
|
||||
}
|
||||
// Nothing to build: Enter ends the equation and starts a new paragraph.
|
||||
@@ -434,6 +596,13 @@ fn enter(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let after = Pos { off: m.at.off + OBJ.len_utf8(), ..m.at };
|
||||
let new = split_para(s, &after)?;
|
||||
s.sel = Selection::caret(new);
|
||||
s.set_ime_edit_outcome(ImeEditOutcome {
|
||||
classified: true,
|
||||
content_changed: true,
|
||||
state_changed: true,
|
||||
transformed: false,
|
||||
selection_changed: false,
|
||||
});
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
@@ -452,11 +621,14 @@ fn convert_math(math: &mut Math, professional: bool, latex: bool) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts one equation or all equations between built-up and linear form, honoring LaTeX mode,
|
||||
/// merging every effect and resetting the math caret.
|
||||
fn convert(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let professional = p::str(v, "to").map(|t| !t.eq_ignore_ascii_case("linear")).unwrap_or(true);
|
||||
let latex = s.math_latex;
|
||||
if p::bool(v, "all").unwrap_or(false) {
|
||||
let mut n = 0;
|
||||
let mut effect = EquationEffect::default();
|
||||
let stories: Vec<wordcraft_doc::StoryRef> =
|
||||
std::iter::once(wordcraft_doc::StoryRef::Body).chain(s.doc.parts.keys().map(|k| wordcraft_doc::StoryRef::Part(*k))).collect();
|
||||
for story in stories {
|
||||
@@ -468,7 +640,8 @@ fn convert(s: &mut Session, v: &Value) -> CmdResult {
|
||||
.unwrap_or_default();
|
||||
for off in offs {
|
||||
let at = Pos { story, path: path.clone(), off };
|
||||
with_eq(s, &at, |math, _| convert_math(math, professional, latex))?;
|
||||
let (_, one) = with_eq(s, &at, |math, _| convert_math(math, professional, latex))?;
|
||||
effect.merge(one);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
@@ -477,26 +650,32 @@ fn convert(s: &mut Session, v: &Value) -> CmdResult {
|
||||
m.pos = MathPos::default();
|
||||
}
|
||||
s.clamp_selection();
|
||||
apply_effect(s, effect, false);
|
||||
return Ok(json!({"converted": n}));
|
||||
}
|
||||
let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?;
|
||||
let mut end = 0;
|
||||
with_eq(s, &at, |math, _| {
|
||||
let (_, effect) = with_eq(s, &at, |math, _| {
|
||||
convert_math(math, professional, latex);
|
||||
end = me::units(&math.nodes);
|
||||
})?;
|
||||
if let Some(m) = s.math.as_mut() {
|
||||
m.pos = MathPos::new(Vec::new(), end);
|
||||
}
|
||||
apply_effect(s, effect, false);
|
||||
result(s)
|
||||
}
|
||||
|
||||
/// Toggles or sets the equation's inline or display mode.
|
||||
fn set_display(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?;
|
||||
with_eq(s, &at, |_, display| *display = p::bool(v, "value").unwrap_or(!*display))?;
|
||||
let (_, effect) = with_eq(s, &at, |_, display| *display = p::bool(v, "value").unwrap_or(!*display))?;
|
||||
apply_effect(s, effect, false);
|
||||
result(s)
|
||||
}
|
||||
|
||||
/// Maps `jc` to the math justification, with unknown values falling back to center-group, and
|
||||
/// forces display mode.
|
||||
fn justify(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?;
|
||||
let jc = match p::str(v, "jc").unwrap_or("centerGroup") {
|
||||
@@ -505,10 +684,11 @@ fn justify(s: &mut Session, v: &Value) -> CmdResult {
|
||||
"center" => MathJc::Center,
|
||||
_ => MathJc::CenterGroup,
|
||||
};
|
||||
with_eq(s, &at, |math, display| {
|
||||
let (_, effect) = with_eq(s, &at, |math, display| {
|
||||
math.jc = jc;
|
||||
*display = true;
|
||||
})?;
|
||||
apply_effect(s, effect, false);
|
||||
result(s)
|
||||
}
|
||||
|
||||
@@ -516,7 +696,7 @@ fn justify(s: &mut Session, v: &Value) -> CmdResult {
|
||||
fn number(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?;
|
||||
let mut pos_reset = false;
|
||||
with_eq(s, &at, |math, display| {
|
||||
let (_, effect) = with_eq(s, &at, |math, display| {
|
||||
let numbered = matches!(math.nodes.as_slice(), [MNode::EqArr { rows }] if rows.iter().any(|r| wordcraft_doc::math::has_number_mark(r)));
|
||||
let want = p::bool(v, "value").unwrap_or(!numbered);
|
||||
if want && !numbered {
|
||||
@@ -539,6 +719,7 @@ fn number(s: &mut Session, v: &Value) -> CmdResult {
|
||||
m.pos = MathPos::default();
|
||||
}
|
||||
s.clamp_selection();
|
||||
apply_effect(s, effect, false);
|
||||
result(s)
|
||||
}
|
||||
|
||||
@@ -562,17 +743,20 @@ fn without_number(row: Arg) -> Arg {
|
||||
out
|
||||
}
|
||||
|
||||
/// Replaces the equation's nodes from linear or LaTeX input, forces built-up form, and puts the
|
||||
/// caret at the new end.
|
||||
fn set(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let at = equation_at_caret(s).ok_or_else(|| CmdError::Disabled("no equation at the caret".into()))?;
|
||||
let nodes = nodes_from(v).ok_or_else(|| CmdError::Params("give linear or latex".into()))?;
|
||||
let end = me::units(&nodes);
|
||||
with_eq(s, &at, |math, _| {
|
||||
let (_, effect) = with_eq(s, &at, |math, _| {
|
||||
math.nodes = nodes;
|
||||
math.linear = false;
|
||||
})?;
|
||||
if let Some(m) = s.math.as_mut() {
|
||||
m.pos = MathPos::new(Vec::new(), end);
|
||||
}
|
||||
apply_effect(s, effect, false);
|
||||
result(s)
|
||||
}
|
||||
|
||||
@@ -601,6 +785,8 @@ fn structure_actions(s: &mut Session, _: &Value) -> CmdResult {
|
||||
Ok(json!({"actions": acts}))
|
||||
}
|
||||
|
||||
/// Applies a named structure action at an explicit or inferred level, refusing an action that
|
||||
/// does not apply at the caret.
|
||||
fn structure(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let m = current(s)?;
|
||||
let action = p::req_str(v, "action")?.to_string();
|
||||
@@ -614,11 +800,12 @@ fn structure(s: &mut Session, v: &Value) -> CmdResult {
|
||||
.ok_or_else(|| CmdError::Params(format!("{action} doesn't apply here")))?,
|
||||
};
|
||||
let mut pos = None;
|
||||
with_eq(s, &m.at, |math, _| pos = me::apply_structure(&mut math.nodes, &m.pos, level, &action))?;
|
||||
let (_, effect) = with_eq(s, &m.at, |math, _| pos = me::apply_structure(&mut math.nodes, &m.pos, level, &action))?;
|
||||
let pos = pos.ok_or_else(|| CmdError::Params(format!("{action} doesn't apply here")))?;
|
||||
if let Some(m) = s.math.as_mut() {
|
||||
m.pos = pos;
|
||||
}
|
||||
apply_effect(s, effect, false);
|
||||
result(s)
|
||||
}
|
||||
|
||||
@@ -858,14 +1045,24 @@ mod tests {
|
||||
assert!(document_xml(&s).contains(">bx<"));
|
||||
}
|
||||
|
||||
/// A Left move that builds up `a/b` makes the document dirty, advances layout and IME revisions,
|
||||
/// reports a classified transformed change, and Undo restores the literal run; plain navigation
|
||||
/// leaves no undo step.
|
||||
#[test]
|
||||
fn navigation_build_up_is_an_undoable_edit() {
|
||||
// #447: building `a/b` up on a Left move left the document clean and Undo did nothing.
|
||||
let mut s = imported("<m:r><m:t>a/b</m:t></m:r>");
|
||||
assert!(!s.dirty);
|
||||
let layout_rev = s.rev();
|
||||
let ime_rev = revision(&mut s);
|
||||
s.run("equation.move", &json!({"dir": "left"})).unwrap();
|
||||
// Every command replaces the last outcome; sample the edit before the read-only query.
|
||||
let outcome = s.ime_edit_outcome();
|
||||
assert_eq!(s.run("equation.get", &json!({})).unwrap()["latex"], "\\frac{a}{b}");
|
||||
assert!(s.dirty, "build-up marks the document dirty");
|
||||
assert!(s.rev() > layout_rev, "build-up refreshes the layout revision");
|
||||
assert!(outcome.classified && outcome.content_changed && outcome.state_changed && outcome.transformed, "{outcome:?}");
|
||||
assert!(revision(&mut s) > ime_rev, "build-up advances the unavailable IME revision");
|
||||
s.run("edit.undo", &json!({})).unwrap();
|
||||
s.run("equation.edit", &json!({})).unwrap();
|
||||
assert_eq!(s.run("equation.get", &json!({})).unwrap()["latex"], "a/b", "Undo restores the plain run");
|
||||
@@ -941,4 +1138,239 @@ mod tests {
|
||||
assert_eq!(subs, ["i", "j"], "{nodes:#?}");
|
||||
assert!(nodes.iter().all(|n| matches!(n, MNode::Script { .. }) || matches!(n, MNode::Run(r) if r.text.trim().is_empty())), "{nodes:#?}");
|
||||
}
|
||||
|
||||
/// Returns the session's current IME state revision, allocating a new one when the caret state changed.
|
||||
fn revision(s: &mut Session) -> u64 {
|
||||
s.ime_state_revision().expect("an IME state revision")
|
||||
}
|
||||
|
||||
/// Clears the stored node tree of the equation currently being edited, leaving its linear text
|
||||
/// and lazy state in place.
|
||||
fn clear_nodes(s: &mut Session) {
|
||||
let at = s.math.as_ref().expect("editing").at.clone();
|
||||
if let Some(InlineObject::Equation { math, .. }) = s.doc.para_mut(at.story, &at.path).expect("paragraph").object_at_mut(at.off) {
|
||||
math.nodes.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Backspace at the start of an equation is classified with no content or state change,
|
||||
/// allocates no revision and leaves the linear text intact.
|
||||
#[test]
|
||||
fn equation_backspace_at_start_is_a_classified_noop() {
|
||||
let mut s = session();
|
||||
s.run("insert.equation", &json!({"linear": "x+y"})).unwrap();
|
||||
s.run("equation.move", &json!({"dir": "home"})).unwrap();
|
||||
let before = revision(&mut s);
|
||||
s.run("equation.backspace", &json!({})).unwrap();
|
||||
let outcome = s.ime_edit_outcome();
|
||||
assert!(outcome.classified);
|
||||
assert!(!outcome.content_changed && !outcome.state_changed, "{outcome:?}");
|
||||
assert_eq!(revision(&mut s), before, "a no-op allocates no revision");
|
||||
assert_eq!(linear(&s), "x+y");
|
||||
}
|
||||
|
||||
/// Materializing an imported equation's lazy nodes is a state change only: the outcome is
|
||||
/// classified, content is unchanged, and the revision advances.
|
||||
#[test]
|
||||
fn lazy_normalization_is_state_not_content() {
|
||||
let mut s = session();
|
||||
s.run("insert.equation", &json!({"linear": "x+1"})).unwrap();
|
||||
clear_nodes(&mut s);
|
||||
// Editing parses the local tree but leaves the stored equation lazy.
|
||||
s.run("equation.edit", &json!({})).unwrap();
|
||||
s.run("equation.move", &json!({"dir": "home"})).unwrap();
|
||||
let before = revision(&mut s);
|
||||
s.run("equation.backspace", &json!({})).unwrap();
|
||||
let outcome = s.ime_edit_outcome();
|
||||
assert!(outcome.classified);
|
||||
assert!(!outcome.content_changed, "the normalized tree was already logical content");
|
||||
assert!(outcome.state_changed, "materializing the cached nodes is a state change");
|
||||
assert!(revision(&mut s) > before, "state changes advance the revision");
|
||||
}
|
||||
|
||||
/// Empty typing is a classified no-op in built-up and linear form, without a new revision.
|
||||
#[test]
|
||||
fn empty_equation_type_is_a_classified_noop_in_both_modes() {
|
||||
let mut s = session();
|
||||
s.run("insert.equation", &json!({"linear": "x"})).unwrap();
|
||||
let before = revision(&mut s);
|
||||
s.run("equation.type", &json!({"text": ""})).unwrap();
|
||||
assert!(s.ime_edit_outcome().classified);
|
||||
assert!(!s.ime_edit_outcome().content_changed && !s.ime_edit_outcome().state_changed);
|
||||
assert_eq!(revision(&mut s), before, "built-up empty type is a no-op");
|
||||
s.run("equation.convert", &json!({"to": "linear"})).unwrap();
|
||||
let before = revision(&mut s);
|
||||
s.run("equation.type", &json!({"text": ""})).unwrap();
|
||||
assert!(!s.ime_edit_outcome().content_changed && !s.ime_edit_outcome().state_changed);
|
||||
assert_eq!(revision(&mut s), before, "linear empty type is a no-op");
|
||||
}
|
||||
|
||||
/// Typing `a/b ` or `\alpha ` reports both a content change and a transformed automatic rewrite.
|
||||
#[test]
|
||||
fn automatic_equation_rewrites_are_transformed() {
|
||||
for text in ["a/b ", "\\alpha "] {
|
||||
let mut s = session();
|
||||
s.run("insert.equation", &json!({})).unwrap();
|
||||
s.run("equation.type", &json!({"text": text})).unwrap();
|
||||
let outcome = s.ime_edit_outcome();
|
||||
assert!(outcome.content_changed, "{text}: {outcome:?}");
|
||||
assert!(outcome.transformed, "{text}: {outcome:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Justification and display changes advance the revision and report a state, not content,
|
||||
/// change.
|
||||
#[test]
|
||||
fn equation_style_changes_are_state_changes_without_content() {
|
||||
let mut s = session();
|
||||
s.run("insert.equation", &json!({"linear": "x"})).unwrap();
|
||||
let before = revision(&mut s);
|
||||
s.run("equation.justify", &json!({"jc": "left"})).unwrap();
|
||||
let outcome = s.ime_edit_outcome();
|
||||
assert!(outcome.classified && outcome.state_changed && !outcome.content_changed, "{outcome:?}");
|
||||
assert!(revision(&mut s) > before);
|
||||
let before = revision(&mut s);
|
||||
s.run("equation.display", &json!({"value": false})).unwrap();
|
||||
assert!(s.ime_edit_outcome().state_changed && !s.ime_edit_outcome().content_changed);
|
||||
assert!(revision(&mut s) > before);
|
||||
}
|
||||
|
||||
/// Converting every equation reports one classified effect that merges their content and state
|
||||
/// changes.
|
||||
#[test]
|
||||
fn convert_all_merges_every_equations_effect() {
|
||||
let mut s = session();
|
||||
s.run("document.setText", &json!({"text": "\r"})).unwrap();
|
||||
s.run("insert.equation", &json!({"linear": "a/b"})).unwrap();
|
||||
s.run("equation.convert", &json!({"to": "linear"})).unwrap();
|
||||
s.run("equation.exit", &json!({})).unwrap();
|
||||
s.run("caret.docEnd", &json!({})).unwrap();
|
||||
s.run("insert.equation", &json!({"linear": "c/d"})).unwrap();
|
||||
s.run("equation.exit", &json!({})).unwrap();
|
||||
s.run("equation.convert", &json!({"to": "professional", "all": true})).unwrap();
|
||||
let outcome = s.ime_edit_outcome();
|
||||
assert!(outcome.classified && outcome.content_changed && outcome.state_changed, "{outcome:?}");
|
||||
}
|
||||
|
||||
/// A bounded deletion around the caret removes the requested units in one undo step and reports
|
||||
/// a content change.
|
||||
#[test]
|
||||
fn equation_delete_surrounding_is_one_transaction_and_one_undo_step() {
|
||||
let mut s = session();
|
||||
s.run("insert.equation", &json!({"linear": "abcd"})).unwrap();
|
||||
let depth = s.undo_depth();
|
||||
s.run("equation.deleteSurrounding", &json!({"before": 2, "after": 0})).unwrap();
|
||||
assert_eq!(linear(&s), "ab");
|
||||
assert_eq!(s.undo_depth(), depth + 1, "one undo step, not one per unit");
|
||||
assert!(s.ime_edit_outcome().classified && s.ime_edit_outcome().content_changed);
|
||||
}
|
||||
|
||||
/// An impossible bounded deletion applies no prefix, adds no undo step, keeps the session and
|
||||
/// caret, and leaves no classified outcome.
|
||||
#[test]
|
||||
fn equation_delete_surrounding_refuses_partial_progress() {
|
||||
let mut s = session();
|
||||
s.run("insert.equation", &json!({"linear": "ab"})).unwrap();
|
||||
let session_token = s.math.as_ref().unwrap().session;
|
||||
let at = s.math.as_ref().unwrap().at.clone();
|
||||
let depth = s.undo_depth();
|
||||
let err = s.run("equation.deleteSurrounding", &json!({"before": 3, "after": 0})).unwrap_err();
|
||||
assert!(err.to_string().contains("cannot delete"), "{err}");
|
||||
assert_eq!(linear(&s), "ab", "the whole request is refused, no prefix applied");
|
||||
assert_eq!(s.undo_depth(), depth, "no undo step for a refused transaction");
|
||||
let m = s.math.as_ref().expect("still editing");
|
||||
assert_eq!(m.session, session_token);
|
||||
assert_eq!(m.at, at);
|
||||
assert!(!s.ime_edit_outcome().classified, "a refusal has no outcome");
|
||||
assert_eq!(
|
||||
s.last_command.as_ref().map(|(id, _)| id.as_str()),
|
||||
Some("equation.deleteSurrounding"),
|
||||
"only the pre-dispatch Repeat record remains"
|
||||
);
|
||||
}
|
||||
|
||||
/// Zero or oversized counts and deletions from an empty equation are refused without mutation;
|
||||
/// exactly deleting all text leaves the empty equation object.
|
||||
#[test]
|
||||
fn equation_delete_surrounding_bounds_and_empty_equations() {
|
||||
let mut s = session();
|
||||
s.run("insert.equation", &json!({})).unwrap();
|
||||
assert!(s.run("equation.deleteSurrounding", &json!({"before": 0, "after": 0})).is_err(), "zero counts are refused");
|
||||
assert!(s.run("equation.deleteSurrounding", &json!({"before": 1, "after": 0})).is_err(), "an empty equation refuses");
|
||||
assert!(s.math.is_some(), "the empty equation object stays");
|
||||
s.run("equation.type", &json!({"text": "a"})).unwrap();
|
||||
let depth = s.undo_depth();
|
||||
assert!(s.run("equation.deleteSurrounding", &json!({"before": 65, "after": 0})).is_err());
|
||||
assert!(s.run("equation.deleteSurrounding", &json!({"before": 0, "after": 65})).is_err());
|
||||
assert_eq!(linear(&s), "a");
|
||||
assert_eq!(s.undo_depth(), depth);
|
||||
s.run("equation.deleteSurrounding", &json!({"before": 1, "after": 0})).unwrap();
|
||||
assert_eq!(linear(&s), "", "exactly deleting the last unit leaves the equation object");
|
||||
assert!(s.math.is_some());
|
||||
}
|
||||
|
||||
/// A forward deletion that would only step into another argument is refused whole, leaving the
|
||||
/// text and undo depth unchanged (the caret is not asserted here).
|
||||
#[test]
|
||||
fn equation_delete_surrounding_refuses_a_caret_only_step() {
|
||||
let mut s = session();
|
||||
s.run("insert.equation", &json!({"latex": "\\frac{a}{b}"})).unwrap();
|
||||
// At the end of the numerator a forward delete only steps into the denominator: the
|
||||
// tree does not change, so a request that would report progress is refused whole.
|
||||
s.math.as_mut().expect("editing").pos = MathPos { path: vec![(0, 0)], off: 1 };
|
||||
let before = linear(&s);
|
||||
let depth = s.undo_depth();
|
||||
let err = s.run("equation.deleteSurrounding", &json!({"before": 0, "after": 1})).unwrap_err();
|
||||
assert!(err.to_string().contains("no progress"), "{err}");
|
||||
assert_eq!(linear(&s), before);
|
||||
assert_eq!(s.undo_depth(), depth);
|
||||
}
|
||||
|
||||
/// A deletion that really erases on one side cannot buy a caret-only step on the other:
|
||||
/// the mixed request is refused whole, so a following IME commit cannot land in the
|
||||
/// argument the platform never asked about.
|
||||
#[test]
|
||||
fn equation_delete_surrounding_refuses_a_mixed_caret_only_step() {
|
||||
let mut s = session();
|
||||
s.run("insert.equation", &json!({"latex": "\\frac{a}{b}"})).unwrap();
|
||||
// At the end of the numerator. `before: 1` erases `a`, but `after: 1` would only step
|
||||
// into the denominator (the report's mixed-direction reproducer).
|
||||
s.math.as_mut().expect("editing").pos = MathPos { path: vec![(0, 0)], off: 1 };
|
||||
let before = linear(&s);
|
||||
let pos = s.math.as_ref().expect("editing").pos.clone();
|
||||
let depth = s.undo_depth();
|
||||
let err = s.run("equation.deleteSurrounding", &json!({"before": 1, "after": 1})).unwrap_err();
|
||||
assert!(err.to_string().contains("no progress"), "{err}");
|
||||
assert_eq!(linear(&s), before, "the successful-looking prefix is not applied");
|
||||
assert_eq!(s.undo_depth(), depth, "no undo step for a refused mixed request");
|
||||
assert_eq!(s.math.as_ref().expect("editing").pos, pos, "the caret stays where the request found it");
|
||||
}
|
||||
|
||||
/// Typing more than the cap inserts exactly `MAX_EQUATION_TYPE_CHARS` characters in both linear
|
||||
/// and built-up form.
|
||||
#[test]
|
||||
fn equation_type_caps_linear_and_built_up_input() {
|
||||
for linear_mode in [false, true] {
|
||||
let mut s = session();
|
||||
s.run("insert.equation", &json!({})).unwrap();
|
||||
if linear_mode {
|
||||
s.run("equation.convert", &json!({"to": "linear"})).unwrap();
|
||||
}
|
||||
let text = "a".repeat(crate::MAX_EQUATION_TYPE_CHARS + 5);
|
||||
s.run("equation.type", &json!({"text": text})).unwrap();
|
||||
assert_eq!(linear(&s).chars().count(), crate::MAX_EQUATION_TYPE_CHARS, "linear={linear_mode}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Deleting one character forward removes it and moves the math caret to the start of the
|
||||
/// remaining text.
|
||||
#[test]
|
||||
fn equation_delete_surrounding_moves_the_caret_for_a_one_step_delete() {
|
||||
let mut s = session();
|
||||
s.run("insert.equation", &json!({"linear": "abc"})).unwrap();
|
||||
s.run("equation.move", &json!({"dir": "home"})).unwrap();
|
||||
s.run("equation.deleteSurrounding", &json!({"after": 1, "before": 0})).unwrap();
|
||||
assert_eq!(linear(&s), "bc");
|
||||
assert_eq!(s.math.as_ref().unwrap().pos, MathPos::new(Vec::new(), 0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +79,10 @@ fn rename(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(json!({"name": name, "path": s.path}))
|
||||
}
|
||||
|
||||
/// Builds the file and document command table: rename, new, open, save, save as and encryption,
|
||||
/// PDF/PNG/print output and print settings, close, properties and info, author and privacy
|
||||
/// settings, and document inspection and text access. Recovery, compatibility, sharing, autosave,
|
||||
/// `file.newFromTemplate` and `file.saveTemplate` are registered in [`super::tools::specs`].
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
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(),
|
||||
@@ -137,8 +141,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
sel_result(s)
|
||||
})
|
||||
.key("Mod+W / Mod+F4")
|
||||
.params(r#"{"force"?: bool (desktop app's control channel and MCP bridge only: without it a document with unsaved changes is an error and stays open; with it the changes are discarded. Headless sessions never ask)}"#)
|
||||
.params(r#"{"force"?: bool (desktop app's control channel and MCP bridge only: without it a document with unsaved changes is an error and stays open; with it the changes are discarded. Headless sessions never ask; file.closeDocument closes a document there)}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("file.closeDocument", "Close Document", "Agents", close_document)
|
||||
.params(r#"{"id"?: n (an open document, see view.switchWindows; default the active one), "force"?: bool (discard unsaved changes; without it a document with unsaved changes is an error and stays open)} → {"closed": id, "active": id, "documents": [{"id", "title", "path", "dirty", "active"}]}; closing the last document leaves a new blank one"#)
|
||||
.pure(),
|
||||
CommandSpec::new("file.properties", "Properties", "File › Info", properties)
|
||||
.params(r#"{"title"?, "subject"?, "author"?, "keywords"?, "comments"?, "category"?, "custom"?: {name: string | null (removes)}}"#),
|
||||
CommandSpec::new("file.info", "Info", "File", info).pure(),
|
||||
@@ -188,6 +195,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let text = p::req_str(v, "text")?;
|
||||
let d = Document::from_text(text);
|
||||
s.doc.body = d.body;
|
||||
// The body is new even though the document id stays: a live IME composition (or an
|
||||
// equation editing session at the same offset) belongs to the old content.
|
||||
s.note_body_replaced();
|
||||
s.math = None;
|
||||
s.sel = crate::Selection::caret(s.doc.start_of(StoryRef::Body));
|
||||
sel_result(s)
|
||||
})
|
||||
@@ -200,7 +211,8 @@ 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.
|
||||
/// Open a new document beside the open ones and make it active: a blank one in the saved
|
||||
/// proofing language, or 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(),
|
||||
@@ -209,14 +221,13 @@ fn new(s: &mut Session, v: &Value) -> CmdResult {
|
||||
"report" => crate::sample::report(),
|
||||
_ => normal_document(s),
|
||||
};
|
||||
s.set_document(doc);
|
||||
s.path = None;
|
||||
s.add_document(doc);
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// A blank document on the Normal template: the region's paper (#838), written in the editing
|
||||
/// language (the templates' text is English).
|
||||
fn normal_document(s: &Session) -> Document {
|
||||
pub(crate) fn normal_document(s: &Session) -> Document {
|
||||
let mut d = s.blank_document();
|
||||
if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref()) {
|
||||
d.styles.default_chr.lang = Some(lang.clone());
|
||||
@@ -224,12 +235,22 @@ fn normal_document(s: &Session) -> Document {
|
||||
d
|
||||
}
|
||||
|
||||
/// Open a document, using the saved proofing default only for plain text and Markdown.
|
||||
/// Open a document, using the saved proofing default only for plain text and Markdown. A file
|
||||
/// that is already open becomes the active document instead (`"alreadyOpen": true`). The file
|
||||
/// replaces an untouched blank document; otherwise it opens beside the open documents.
|
||||
fn open(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let Some(path) = p::str(v, "path") else {
|
||||
s.push_ui_request(json!({"open": "openFile"}));
|
||||
return sel_result(s);
|
||||
};
|
||||
if p::str(v, "data").is_none()
|
||||
&& let Some(id) = s.find_open(std::path::Path::new(path))
|
||||
{
|
||||
s.activate(id)?;
|
||||
return Ok(
|
||||
json!({"path": s.path, "paragraphs": s.doc.paragraph_count(), "words": s.doc.word_count(), "encrypted": s.password.is_some(), "alreadyOpen": true, "id": id}),
|
||||
);
|
||||
}
|
||||
let password = p::str(v, "password");
|
||||
let (mut doc, encrypted) = if let Some(data) = p::str(v, "data") {
|
||||
let bytes = super::insert::base64_decode(data).ok_or_else(|| CmdError::Params("bad base64".into()))?;
|
||||
@@ -252,7 +273,13 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
|
||||
{
|
||||
doc.styles.default_chr.lang = Some(lang.clone());
|
||||
}
|
||||
s.set_document(doc);
|
||||
match s.used_because() {
|
||||
None => s.set_document(doc),
|
||||
Some(why) => {
|
||||
log::info!("file.open: opened beside document {}, not in its place: {why}", s.document_id());
|
||||
s.add_opened_document(doc);
|
||||
}
|
||||
}
|
||||
if let Some(z) = s.doc.settings.zoom {
|
||||
super::view::restore_zoom(s, z);
|
||||
}
|
||||
@@ -261,7 +288,26 @@ fn open(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if encrypted {
|
||||
s.password = password.map(crate::io::Password::new);
|
||||
}
|
||||
Ok(json!({"path": path, "paragraphs": s.doc.paragraph_count(), "words": s.doc.word_count(), "encrypted": encrypted}))
|
||||
Ok(
|
||||
json!({"path": path, "paragraphs": s.doc.paragraph_count(), "words": s.doc.word_count(), "encrypted": encrypted, "alreadyOpen": false, "id": s.document_id()}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Close a document with no window to ask about it (agents, headless sessions).
|
||||
fn close_document(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let id = match v.get("id").filter(|x| !x.is_null()) {
|
||||
None => s.document_id(),
|
||||
Some(x) => x.as_u64().ok_or_else(|| CmdError::Params("`id` must be an open document's id".into()))?,
|
||||
};
|
||||
if !s.is_open(id) {
|
||||
return Err(CmdError::Params(format!("no open document has id {id}")));
|
||||
}
|
||||
let dirty = s.documents().iter().any(|d| d.id == id && d.dirty);
|
||||
if dirty && p::bool(v, "force") != Some(true) {
|
||||
return Err(CmdError::Failed("the document has unsaved changes: save it first, or pass \"force\": true to discard them".into()));
|
||||
}
|
||||
s.close_document(id)?;
|
||||
Ok(json!({"closed": id, "active": s.document_id(), "documents": s.documents()}))
|
||||
}
|
||||
|
||||
/// File › Info › Protect Document › Encrypt with Password: the password Word documents are saved
|
||||
@@ -416,15 +462,25 @@ fn properties(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let Some(m) = custom.as_object() else {
|
||||
return Err(CmdError::Params("`custom` must be an object of name → string or null".into()));
|
||||
};
|
||||
// Removals first: a case-only rename sends the old spelling as null and the new one
|
||||
// with its value, and `set_custom_prop` matches names case-insensitively, so setting
|
||||
// first would keep the old spelling (object keys are sorted, either spelling may lead).
|
||||
for (name, val) in m {
|
||||
if name.trim().is_empty() || name.chars().count() > MAX_CUSTOM_NAME {
|
||||
return Err(CmdError::Params(format!("custom property names are 1–{MAX_CUSTOM_NAME} characters")));
|
||||
if val.is_null() {
|
||||
s.doc.remove_custom_prop(name);
|
||||
}
|
||||
}
|
||||
for (name, val) in m {
|
||||
match val {
|
||||
Value::Null => {
|
||||
s.doc.remove_custom_prop(name);
|
||||
// Removals took the name as stored above: a file opened from an older Word may
|
||||
// hold one longer than today's limit, and it must still be deletable (#2982).
|
||||
Value::Null => {}
|
||||
Value::String(x) if x.len() <= MAX_CUSTOM_VALUE => {
|
||||
if name.trim().is_empty() || name.chars().count() > MAX_CUSTOM_NAME {
|
||||
return Err(CmdError::Params(format!("custom property names are 1–{MAX_CUSTOM_NAME} characters")));
|
||||
}
|
||||
s.doc.set_custom_prop(name, x);
|
||||
}
|
||||
Value::String(x) if x.len() <= MAX_CUSTOM_VALUE => s.doc.set_custom_prop(name, x),
|
||||
_ => return Err(CmdError::Params(format!("custom property `{name}` must be a string (≤ {MAX_CUSTOM_VALUE} bytes) or null"))),
|
||||
}
|
||||
}
|
||||
@@ -436,10 +492,12 @@ fn properties(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Longest custom property name (Word's limit).
|
||||
const MAX_CUSTOM_NAME: usize = 255;
|
||||
/// Largest custom property value we accept from a command.
|
||||
const MAX_CUSTOM_VALUE: usize = 1024 * 1024;
|
||||
/// Longest custom property name (Word's limit). The Custom Properties dialog validates with it
|
||||
/// too (#2982).
|
||||
pub const MAX_CUSTOM_NAME: usize = 255;
|
||||
/// Largest custom property value we accept from a command. The Custom Properties dialog
|
||||
/// validates with it too (#2982).
|
||||
pub const MAX_CUSTOM_VALUE: usize = 1024 * 1024;
|
||||
|
||||
fn info(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let l = s.layout();
|
||||
|
||||
@@ -345,15 +345,20 @@ pub enum Piece {
|
||||
Lit(String),
|
||||
/// `^&`: the text found.
|
||||
Found,
|
||||
/// `\n` with wildcards: group `n`.
|
||||
/// `\n` with wildcards, or `$n` / `${n}` / `\n` with a regex: group `n` (0 is the match).
|
||||
Group(usize),
|
||||
/// `${name}` / `$name` with a regex: a named group, resolved to a [`Piece::Group`] once the
|
||||
/// pattern is built ([`resolve_names`]); one left unresolved expands to nothing.
|
||||
Named(String),
|
||||
/// `^c`: the clipboard.
|
||||
Clip,
|
||||
}
|
||||
|
||||
/// Read Replace with text: `^` codes (unless `literal`), and `\1`–`\9` with wildcards.
|
||||
/// Read Replace with text: `^` codes (unless `literal`), `\1`–`\9` with wildcards, and with a
|
||||
/// regex also `$n`, `${n}`, `${name}`, `$name`, `$$`, `\n` (paragraph mark), `\t` and `\\`.
|
||||
pub fn parse_replace(with: &str, o: &Opts) -> Vec<Piece> {
|
||||
if o.literal || o.regex {
|
||||
// With a regex, `special: false` turns off only the `^` codes; group references still expand.
|
||||
if o.literal && !o.regex {
|
||||
return vec![Piece::Lit(with.to_string())];
|
||||
}
|
||||
let chars: Vec<char> = with.chars().collect();
|
||||
@@ -367,6 +372,22 @@ pub fn parse_replace(with: &str, o: &Opts) -> Vec<Piece> {
|
||||
let mut i = 0;
|
||||
while let Some(&c) = chars.get(i) {
|
||||
i += 1;
|
||||
if o.regex && (c == '$' || c == '\\') {
|
||||
match regex_ref(&chars, i, c) {
|
||||
Some((Ref::Char(ch), used)) => {
|
||||
cur.push(ch);
|
||||
i += used;
|
||||
}
|
||||
Some((Ref::Group(pc), used)) => {
|
||||
flush(&mut cur, &mut out);
|
||||
out.extend(pc);
|
||||
i += used;
|
||||
}
|
||||
// Not a reference or an escape: the character as written.
|
||||
None => cur.push(c),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if c == '\\' && o.wildcards {
|
||||
match chars.get(i) {
|
||||
Some(d) if d.is_ascii_digit() && *d != '0' => {
|
||||
@@ -384,6 +405,7 @@ pub fn parse_replace(with: &str, o: &Opts) -> Vec<Piece> {
|
||||
}
|
||||
}
|
||||
if c == '^'
|
||||
&& !o.literal
|
||||
&& let Some(&n) = chars.get(i)
|
||||
{
|
||||
if n == '&' || n == 'c' {
|
||||
@@ -409,6 +431,67 @@ pub fn parse_replace(with: &str, o: &Opts) -> Vec<Piece> {
|
||||
out
|
||||
}
|
||||
|
||||
/// What a `$` or `\` starts in regex Replace with text.
|
||||
enum Ref {
|
||||
/// An escape: `$$`, `\n`, `\t`, `\\`.
|
||||
Char(char),
|
||||
/// A group reference; `None` for a number too large to be a group (expands to nothing).
|
||||
Group(Option<Piece>),
|
||||
}
|
||||
|
||||
/// A group named by its number or name, as the `regex` crate reads it.
|
||||
fn group_ref(name: &str) -> Option<Piece> {
|
||||
if name.bytes().all(|b| b.is_ascii_digit()) { name.parse().ok().map(Piece::Group) } else { Some(Piece::Named(name.to_string())) }
|
||||
}
|
||||
|
||||
/// Read a regex reference or escape after `c` (`$` or `\`) at `chars[i..]`: what it is and how
|
||||
/// many characters it uses after `c`, or `None` when `c` is a plain character.
|
||||
fn regex_ref(chars: &[char], i: usize, c: char) -> Option<(Ref, usize)> {
|
||||
let next = *chars.get(i)?;
|
||||
if c == '\\' {
|
||||
return match next {
|
||||
'n' => Some((Ref::Char(PARA_MARK), 1)),
|
||||
't' => Some((Ref::Char('\t'), 1)),
|
||||
// `\$` and `\^` keep a `$` or `^` as typed (no group, no `^` code).
|
||||
'\\' | '$' | '^' => Some((Ref::Char(next), 1)),
|
||||
'1'..='9' => Some((Ref::Group(next.to_digit(10).map(|d| Piece::Group(d as usize))), 1)),
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
if next == '$' {
|
||||
return Some((Ref::Char('$'), 1));
|
||||
}
|
||||
let rest = chars.get(i..).unwrap_or_default();
|
||||
if next == '{' {
|
||||
// `${…}`: everything up to the closing brace; unclosed or empty, the `$` is literal.
|
||||
let close = rest.iter().position(|&ch| ch == '}')?;
|
||||
let name: String = rest.get(1..close)?.iter().collect();
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
return Some((Ref::Group(group_ref(&name)), close + 1));
|
||||
}
|
||||
// `$name`: the longest run of letters, digits and `_`.
|
||||
let len = rest.iter().take_while(|ch| ch.is_ascii_alphanumeric() || **ch == '_').count();
|
||||
if len == 0 {
|
||||
return None;
|
||||
}
|
||||
let name: String = rest.get(..len)?.iter().collect();
|
||||
Some((Ref::Group(group_ref(&name)), len))
|
||||
}
|
||||
|
||||
/// Turn named group references into numbered ones with `index` (a name's group number);
|
||||
/// an unknown name is dropped, so it expands to nothing.
|
||||
pub fn resolve_names(pieces: Vec<Piece>, index: &dyn Fn(&str) -> Option<usize>) -> Vec<Piece> {
|
||||
pieces
|
||||
.into_iter()
|
||||
.filter_map(|p| match p {
|
||||
Piece::Named(n) => index(&n).map(Piece::Group),
|
||||
p => Some(p),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Searched text as it is inserted again: marks become paragraph breaks.
|
||||
fn as_inserted(s: &str) -> String {
|
||||
s.replace(SECT_MARK, "\r")
|
||||
@@ -423,6 +506,7 @@ pub fn expand(pieces: &[Piece], found: &str, group: &dyn Fn(usize) -> Option<Str
|
||||
Piece::Found => out.push_str(&as_inserted(found)),
|
||||
Piece::Group(n) => out.push_str(&as_inserted(&group(*n).unwrap_or_default())),
|
||||
Piece::Clip => out.push_str(&clip.replace("\r\n", "\r").replace('\n', "\r")),
|
||||
Piece::Named(_) => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
|
||||
@@ -43,6 +43,15 @@ fn read_font_file(path: &str) -> Result<Vec<u8>, CmdError> {
|
||||
crate::io::read_file_capped(path, wordcraft_fonts::MAX_FONT_FILE_BYTES as u64, "font file").map_err(CmdError::Failed)
|
||||
}
|
||||
|
||||
/// Adding or removing a font writes to the user font folder: automation confined to a write root
|
||||
/// (#769) may do that only when the folder is inside it.
|
||||
fn check_font_store(dir: Option<&std::path::Path>) -> Result<(), CmdError> {
|
||||
match dir {
|
||||
Some(dir) => crate::file_access::check_write(&dir.to_string_lossy()).map_err(CmdError::Failed),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if p::str(v, "url").is_some() {
|
||||
return Err(CmdError::Failed(
|
||||
@@ -71,6 +80,7 @@ fn add(s: &mut Session, v: &Value) -> CmdResult {
|
||||
s.push_ui_request(json!({"open": "addFont"}));
|
||||
return Ok(json!({"pending": true}));
|
||||
};
|
||||
check_font_store(FontDb::global().user_font_dir().as_deref())?;
|
||||
let r = FontDb::global().install_font(&name, &bytes).map_err(|e| CmdError::Failed(e.to_string()))?;
|
||||
// Text already set in the family (shown in a fallback until now) takes the new font.
|
||||
s.relayout();
|
||||
@@ -100,8 +110,33 @@ fn list(_: &mut Session, v: &Value) -> CmdResult {
|
||||
|
||||
fn remove(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let family = p::str(v, "family").filter(|f| !f.trim().is_empty()).ok_or_else(|| CmdError::Params("family is required".into()))?;
|
||||
check_font_store(FontDb::global().user_font_dir().as_deref())?;
|
||||
let gone = FontDb::global().remove_user_font(family.trim()).map_err(|e| CmdError::Failed(e.to_string()))?;
|
||||
s.relayout();
|
||||
s.status = format!("Removed {}", family.trim());
|
||||
Ok(json!({"removed": gone.iter().map(font_json).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn confined_automation_changes_only_a_font_store_inside_its_write_root() {
|
||||
let root = std::env::temp_dir().join(format!("wordcraft-font-store-{}", std::process::id()));
|
||||
let outside = std::env::temp_dir().join(format!("wordcraft-font-store-out-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::create_dir_all(&outside).unwrap();
|
||||
let write = crate::file_access::AutomationRoots::new(None, Some(&root)).unwrap();
|
||||
let read_only = crate::file_access::AutomationRoots::new(Some(&root), None).unwrap();
|
||||
crate::file_access::confine(write.as_ref(), || {
|
||||
assert!(check_font_store(None).is_ok(), "no folder: the font lasts for the session");
|
||||
assert!(check_font_store(Some(&root.join("fonts"))).is_ok());
|
||||
assert!(check_font_store(Some(&outside)).is_err());
|
||||
});
|
||||
crate::file_access::confine(read_only.as_ref(), || assert!(check_font_store(Some(&root)).is_err()));
|
||||
assert!(check_font_store(Some(&outside)).is_ok(), "unconfined: the person at the keyboard");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let _ = std::fs::remove_dir_all(&outside);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,19 +401,9 @@ fn target_has_highlight(s: &Session, h: Highlight) -> bool {
|
||||
if let Some(rows) = column_rows(s) {
|
||||
for (a, b) in rows {
|
||||
let Some(p) = s.doc.para_at(&a) else { continue };
|
||||
if a == b || (a.off == 0 && b.off == p.len()) {
|
||||
seen = true;
|
||||
if !matches(p.props.style.as_deref(), &p.mark) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (r, c) in p.run_ranges() {
|
||||
if r.end > a.off && r.start < b.off {
|
||||
seen = true;
|
||||
if !matches(p.props.style.as_deref(), c) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let mark = a == b || (a.off == 0 && b.off == p.len());
|
||||
if !span_matches(p, mark, a.off, b.off, &matches, &mut seen) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if seen {
|
||||
@@ -426,19 +416,9 @@ fn target_has_highlight(s: &Session, h: Highlight) -> bool {
|
||||
let from = if path == a.path { a.off } else { 0 };
|
||||
let to = if path == b.path { b.off } else { p.len() };
|
||||
// Match format_range: fully selected paragraphs include their mark.
|
||||
if to == p.len() && (from == 0 || path != a.path) {
|
||||
seen = true;
|
||||
if !matches(p.props.style.as_deref(), &p.mark) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (r, c) in p.run_ranges() {
|
||||
if r.end > from && r.start < to {
|
||||
seen = true;
|
||||
if !matches(p.props.style.as_deref(), c) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let mark = to == p.len() && (from == 0 || path != a.path);
|
||||
if !span_matches(p, mark, from, to, &matches, &mut seen) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -446,6 +426,34 @@ fn target_has_highlight(s: &Session, h: Highlight) -> bool {
|
||||
seen || resolved(s).iter().all(|r| r.highlight == h.rgb())
|
||||
}
|
||||
|
||||
/// Whether the paragraph's mark (when `mark`) and the runs overlapping `from..to` all match
|
||||
/// `matches`; `seen` records that something was checked.
|
||||
fn span_matches(
|
||||
p: &wordcraft_doc::Paragraph,
|
||||
mark: bool,
|
||||
from: usize,
|
||||
to: usize,
|
||||
matches: &impl Fn(Option<&str>, &CharProps) -> bool,
|
||||
seen: &mut bool,
|
||||
) -> bool {
|
||||
let style = p.props.style.as_deref();
|
||||
if mark {
|
||||
*seen = true;
|
||||
if !matches(style, &p.mark) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (r, c) in p.run_ranges() {
|
||||
if r.end > from && r.start < to {
|
||||
*seen = true;
|
||||
if !matches(style, c) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Explicit palette/automation colours set highlighting; the shortcut (no colour) toggles
|
||||
/// the last choice off only when the whole formatting target already has that colour.
|
||||
fn highlight(s: &mut Session, v: &Value) -> CmdResult {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::para::{Float, InlineObject, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::{Align, CharProps, Rgb, TabAlign, TabStop, TextColor};
|
||||
use wordcraft_doc::props::{Align, Border, Borders, CharProps, Rgb, TabAlign, TabStop, TextColor};
|
||||
use wordcraft_doc::{Block, Paragraph, PartKind, Path, Pos, StoryRef, Table, para_block};
|
||||
|
||||
use super::{delete_selection, sel_result, split_para, type_text};
|
||||
@@ -586,18 +586,42 @@ fn hf_part(s: &mut Session, header: bool) -> u32 {
|
||||
id
|
||||
}
|
||||
|
||||
/// Three independently editable cells spanning the section's text width.
|
||||
fn three_column_hf(s: &Session, style: &str) -> wordcraft_doc::Blocks {
|
||||
let block = s.sel.focus.path.0.first().copied().unwrap_or(0) as usize;
|
||||
// The section `section_mut` would pick, read without making anything unique.
|
||||
let sections = s.doc.sections();
|
||||
let own = sections.iter().position(|(end, _)| block <= *end).filter(|i| i + 1 < sections.len()).and_then(|i| sections.get(i));
|
||||
let width = own.map_or_else(|| s.doc.last_section.text_width(), |(_, sec)| sec.text_width());
|
||||
let mut table = Table::new(1, 3, width);
|
||||
table.props.style = Some("TableNormal".into());
|
||||
table.props.width = Some(width);
|
||||
table.props.fixed = true;
|
||||
// Explicit nil borders also override borders inherited from the document's table style.
|
||||
table.props.borders = Some(Borders::all(Border::default()));
|
||||
for row in &mut table.rows {
|
||||
for (cell, align) in row.cells.iter_mut().zip([Align::Left, Align::Center, Align::Right]) {
|
||||
let mut para = Paragraph::new().styled(style);
|
||||
para.props.align = Some(align);
|
||||
cell.blocks = vec![para_block(para)];
|
||||
}
|
||||
}
|
||||
// Keep a trailing paragraph so the story can also be edited after the table.
|
||||
vec![std::sync::Arc::new(Block::Table(table)), para_block(Paragraph::new().styled(style))]
|
||||
}
|
||||
|
||||
fn header_footer(s: &mut Session, v: &Value, header: bool) -> CmdResult {
|
||||
let id = hf_part(s, header);
|
||||
let style = if header { "Header" } else { "Footer" };
|
||||
if let Some(text) = p::str(v, "text") {
|
||||
let blocks = text.split('\n').map(|l| para_block(Paragraph::with_text(l, CharProps::default()).styled(style))).collect();
|
||||
s.doc.set_story(StoryRef::Part(id), blocks)?;
|
||||
} else if p::str(v, "preset") == Some("blankThree") {
|
||||
let blocks = three_column_hf(s, style);
|
||||
s.doc.set_story(StoryRef::Part(id), blocks)?;
|
||||
} else if let Some(preset) = p::str(v, "preset") {
|
||||
let mut para = Paragraph::new().styled(style);
|
||||
match preset {
|
||||
"blankThree" => {
|
||||
para.insert_text(0, "[Type here]\t[Type here]\t[Type here]", &CharProps::default())?;
|
||||
}
|
||||
"title" => {
|
||||
para.insert_text(0, if s.doc.core.title.is_empty() { "[Document title]" } else { &s.doc.core.title }, &CharProps::default())?;
|
||||
para.props.align = Some(Align::Center);
|
||||
|
||||
@@ -522,7 +522,8 @@ fn insert_field(s: &mut Session, instr: &str, placeholder: &str) -> CmdResult {
|
||||
}
|
||||
|
||||
/// Parse CSV (RFC 4180-ish: quotes, doubled quotes, commas/semicolons/tabs).
|
||||
pub fn parse_csv(text: &str) -> Vec<Vec<String>> {
|
||||
/// The field separator: whichever of `,` `;` and tab the first line (outside quotes) uses most.
|
||||
fn csv_separator(text: &str) -> char {
|
||||
let first = text.lines().next().unwrap_or("");
|
||||
let mut quoted = false;
|
||||
let mut unquoted = String::new();
|
||||
@@ -533,7 +534,20 @@ pub fn parse_csv(text: &str) -> Vec<Vec<String>> {
|
||||
unquoted.push(c);
|
||||
}
|
||||
}
|
||||
let sep = [',', ';', '\t'].into_iter().max_by_key(|c| unquoted.matches(*c).count()).unwrap_or(',');
|
||||
[',', ';', '\t'].into_iter().max_by_key(|c| unquoted.matches(*c).count()).unwrap_or(',')
|
||||
}
|
||||
|
||||
/// Ends the current row: kept when it has any non-empty field, otherwise dropped.
|
||||
fn push_csv_row(rows: &mut Vec<Vec<String>>, row: &mut Vec<String>) {
|
||||
if row.iter().any(|x| !x.is_empty()) {
|
||||
rows.push(std::mem::take(row));
|
||||
} else {
|
||||
row.clear();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_csv(text: &str) -> Vec<Vec<String>> {
|
||||
let sep = csv_separator(text);
|
||||
let mut rows = Vec::new();
|
||||
let mut row = Vec::new();
|
||||
let mut cell = String::new();
|
||||
@@ -560,11 +574,7 @@ pub fn parse_csv(text: &str) -> Vec<Vec<String>> {
|
||||
chars.next();
|
||||
}
|
||||
row.push(std::mem::take(&mut cell));
|
||||
if row.iter().any(|x| !x.is_empty()) {
|
||||
rows.push(std::mem::take(&mut row));
|
||||
} else {
|
||||
row.clear();
|
||||
}
|
||||
push_csv_row(&mut rows, &mut row);
|
||||
} else {
|
||||
cell.push(c);
|
||||
}
|
||||
@@ -573,9 +583,7 @@ pub fn parse_csv(text: &str) -> Vec<Vec<String>> {
|
||||
}
|
||||
}
|
||||
row.push(cell);
|
||||
if row.iter().any(|x| !x.is_empty()) {
|
||||
rows.push(row);
|
||||
}
|
||||
push_csv_row(&mut rows, &mut row);
|
||||
rows
|
||||
}
|
||||
|
||||
@@ -785,7 +793,13 @@ fn merge_fields(doc: &Document) -> Vec<String> {
|
||||
}
|
||||
|
||||
fn mergefield_name(instr: &str) -> Option<String> {
|
||||
let rest = instr.trim().strip_prefix("MERGEFIELD")?.trim();
|
||||
// The field type ignores case and ends at a space or a quote, so `MERGEFIELDX` is another field.
|
||||
let t = instr.trim();
|
||||
let (kind, rest) = t.split_at_checked(t.find(|c: char| c.is_whitespace() || c == '"').unwrap_or(t.len()))?;
|
||||
if !kind.eq_ignore_ascii_case("MERGEFIELD") {
|
||||
return None;
|
||||
}
|
||||
let rest = rest.trim();
|
||||
let name = if let Some(r) = rest.strip_prefix('"') { r.split('"').next().unwrap_or("") } else { rest.split_whitespace().next().unwrap_or("") };
|
||||
Some(name.to_string())
|
||||
}
|
||||
@@ -840,11 +854,15 @@ enum Act {
|
||||
Skip,
|
||||
}
|
||||
|
||||
/// The merge field a rule tests: the name in its first nested field, if that is a MERGEFIELD.
|
||||
fn rule_field(t: &str) -> Option<String> {
|
||||
mergefield_name(t.split_once('{').and_then(|(_, r)| r.split('}').next())?)
|
||||
}
|
||||
|
||||
/// Comparison of a rule condition `{ MERGEFIELD X } op "value" …` (IF, SKIPIF, NEXTIF) for
|
||||
/// `row`: whether it holds, and the quoted texts after the value (IF's true / false text).
|
||||
fn condition<'a>(s: &Session, t: &'a str, row: usize) -> (bool, Vec<&'a str>) {
|
||||
let inner = t.split_once('{').and_then(|(_, r)| r.split('}').next()).unwrap_or("");
|
||||
let field = mergefield_name(inner).unwrap_or_default();
|
||||
let field = rule_field(t).unwrap_or_default();
|
||||
let rest = t.split_once('}').map(|(_, r)| r).unwrap_or("");
|
||||
let op: String = rest.trim_start().chars().take_while(|c| matches!(c, '<'..='>')).collect();
|
||||
let mut quoted = rest.split('"').skip(1).step_by(2);
|
||||
@@ -887,7 +905,7 @@ fn act(s: &Session, instr: &str, row: usize) -> Option<Act> {
|
||||
"SKIPIF" if condition(s, t, row).0 => Act::Skip,
|
||||
"NEXTIF" | "SKIPIF" => Act::Text(String::new()),
|
||||
// IF { MERGEFIELD X } = "v" "then" "else" (an IF on other fields isn't ours to touch).
|
||||
"IF" if t.contains("MERGEFIELD") => {
|
||||
"IF" if rule_field(t).is_some() => {
|
||||
let (holds, texts) = condition(s, t, row);
|
||||
Act::Text(texts.get(if holds { 0 } else { 1 }).copied().unwrap_or("").to_string())
|
||||
}
|
||||
@@ -904,7 +922,7 @@ fn placeholder(instr: &str) -> Option<String> {
|
||||
"ADDRESSBLOCK" => Some("«AddressBlock»".into()),
|
||||
"GREETINGLINE" => Some("«GreetingLine»".into()),
|
||||
"NEXT" | "NEXTIF" | "SKIPIF" | "MERGEREC" => Some(String::new()),
|
||||
"IF" if instr.contains("MERGEFIELD") => Some(String::new()),
|
||||
"IF" if rule_field(instr).is_some() => Some(String::new()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1086,10 +1104,8 @@ fn finish(s: &mut Session, v: &Value) -> CmdResult {
|
||||
crate::io::save_path(std::path::Path::new(path), &out).map_err(CmdError::Failed)?;
|
||||
return Ok(json!({"records": count, "path": path}));
|
||||
}
|
||||
s.set_document(out);
|
||||
s.path = None;
|
||||
s.add_document(out);
|
||||
s.dirty = true;
|
||||
s.merge.preview = false;
|
||||
Ok(json!({"records": count}))
|
||||
}
|
||||
|
||||
@@ -1251,8 +1267,7 @@ fn envelopes(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let mut d = Document::new();
|
||||
d.last_section = sect;
|
||||
d.body = blocks;
|
||||
s.set_document(d);
|
||||
s.path = None;
|
||||
s.add_document(d);
|
||||
Ok(json!({"width": w, "height": h}))
|
||||
}
|
||||
|
||||
@@ -1484,8 +1499,7 @@ fn labels(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
}
|
||||
d.body = vec![std::sync::Arc::new(Block::Table(t)), para_block(Paragraph::new())];
|
||||
s.set_document(d);
|
||||
s.path = None;
|
||||
s.add_document(d);
|
||||
let _ = Pos::body(0, 0);
|
||||
Ok(json!({"labels": if single.is_some() { 1 } else { rows * cols }, "filled": made, "product": prod.id}))
|
||||
}
|
||||
@@ -1546,6 +1560,41 @@ mod tests {
|
||||
assert_eq!(r, vec![vec!["Name, first, last", "City"], vec!["Ann", "Paris"]]);
|
||||
}
|
||||
|
||||
/// Field types ignore case (ECMA-376 Part 1, 17.16.1), and `MERGEFIELDX` is another field.
|
||||
#[test]
|
||||
fn merge_field_types_ignore_case() {
|
||||
assert_eq!(mergefield_name(" mergefield City ").as_deref(), Some("City"));
|
||||
assert_eq!(mergefield_name(r#"MergeField "First Name""#).as_deref(), Some("First Name"));
|
||||
assert_eq!(mergefield_name(r#"MERGEFIELD"City""#).as_deref(), Some("City"));
|
||||
assert_eq!(mergefield_name("MERGEFIELDX City"), None);
|
||||
assert_eq!(placeholder("mergefield City").as_deref(), Some("«City»"));
|
||||
assert_eq!(placeholder(r#"if { mergefield City } = "x" "y" "z""#).as_deref(), Some(""));
|
||||
let mut s = Session::new(Document::new());
|
||||
s.run("mailings.recipients", &json!({"rows": [{"Name": "Alpha"}, {"Name": "Beta"}]})).unwrap();
|
||||
s.run("mailings.start", &json!({"kind": "directory"})).unwrap();
|
||||
s.run("insert.field", &json!({"instr": "mergefield Name", "result": ""})).unwrap();
|
||||
s.run("insert.field", &json!({"instr": r#"if { mergefield Name } = "Beta" " (b)" """#, "result": ""})).unwrap();
|
||||
s.run("mailings.finish", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Alpha\nBeta (b)");
|
||||
}
|
||||
|
||||
/// An IF is a merge rule only when the field it tests is a MERGEFIELD: the word in a quoted
|
||||
/// value doesn't make `IF { PAGE } = "mergefield" …` one.
|
||||
#[test]
|
||||
fn an_if_on_another_field_is_not_a_merge_rule() {
|
||||
let other = r#"IF { PAGE } = "mergefield" "yes" "no""#;
|
||||
assert_eq!(placeholder(other), None);
|
||||
assert_eq!(placeholder(r#"IF { PAGE } = { MERGEFIELD City } "yes" "no""#), None);
|
||||
assert_eq!(placeholder(r#"IF { MERGEFIELD City } = "mergefield" "yes" "no""#).as_deref(), Some(""));
|
||||
let mut s = Session::new(Document::new());
|
||||
s.run("mailings.recipients", &json!({"rows": [{"Name": "Alpha"}]})).unwrap();
|
||||
s.run("mailings.start", &json!({"kind": "directory"})).unwrap();
|
||||
s.run("insert.field", &json!({"instr": "mergefield Name", "result": ""})).unwrap();
|
||||
s.run("insert.field", &json!({"instr": other, "result": " kept"})).unwrap();
|
||||
s.run("mailings.finish", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Alpha kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_end_to_end() {
|
||||
let mut s = Session::new(Document::new());
|
||||
|
||||
+100
-15
@@ -150,16 +150,43 @@ pub fn selected_paths(s: &Session) -> Vec<wordcraft_doc::Path> {
|
||||
s.doc.para_paths_in(&a, &b)
|
||||
}
|
||||
|
||||
/// Delete the selection (respecting track changes); returns the collapsed position.
|
||||
pub fn delete_selection(s: &mut Session) -> Result<Pos, CmdError> {
|
||||
/// What one selection deletion actually did, for IME outcome classification.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct SelectionDelete {
|
||||
pub at: Pos,
|
||||
pub content_changed: bool,
|
||||
pub state_changed: bool,
|
||||
}
|
||||
|
||||
/// Delete the selection (respecting track changes), reporting whether committed content changed.
|
||||
///
|
||||
/// Under Track Changes an all-already-deleted range is a real no-op: no revision record is
|
||||
/// created and the effect says so.
|
||||
pub(crate) fn delete_selection_effect(s: &mut Session) -> Result<SelectionDelete, CmdError> {
|
||||
let (a, b) = s.sel.ordered();
|
||||
if a == b {
|
||||
return Ok(a);
|
||||
return Ok(SelectionDelete { at: a, content_changed: false, state_changed: false });
|
||||
}
|
||||
controls::check_delete(s, &a, &b)?;
|
||||
let at = if s.doc.settings.track_changes { track_delete(s, &a, &b)? } else { s.doc.delete_range(&a, &b)? };
|
||||
s.sel = crate::Selection::caret(at.clone());
|
||||
Ok(at)
|
||||
let effect = if s.doc.settings.track_changes {
|
||||
if !track_delete_would_change(s, &a, &b) {
|
||||
SelectionDelete { at: a.clone(), content_changed: false, state_changed: false }
|
||||
} else {
|
||||
let rid = new_revision(s, RevisionKind::Delete);
|
||||
let deleted = track_delete_as(s, &a, &b, rid)?;
|
||||
SelectionDelete { at: deleted.at, content_changed: deleted.changed, state_changed: deleted.changed }
|
||||
}
|
||||
} else {
|
||||
let at = s.doc.delete_range(&a, &b)?;
|
||||
SelectionDelete { at, content_changed: true, state_changed: true }
|
||||
};
|
||||
s.sel = crate::Selection::caret(effect.at.clone());
|
||||
Ok(effect)
|
||||
}
|
||||
|
||||
/// Delete the selection (respecting track changes); returns the collapsed position.
|
||||
pub fn delete_selection(s: &mut Session) -> Result<Pos, CmdError> {
|
||||
delete_selection_effect(s).map(|e| e.at)
|
||||
}
|
||||
|
||||
/// A revision record for the current author.
|
||||
@@ -217,15 +244,47 @@ pub fn remove_empty_para(s: &mut Session, story: StoryRef, path: &wordcraft_doc:
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Whether a tracked deletion of `a..b` would change anything: any non-deleted run intersects
|
||||
/// the range, or a removable/markable paragraph mark lies inside it.
|
||||
pub(crate) fn track_delete_would_change(s: &Session, a: &Pos, b: &Pos) -> bool {
|
||||
let paths = s.doc.paths_between(a, b);
|
||||
for path in &paths {
|
||||
let Some(p) = s.doc.para(a.story, path) else { continue };
|
||||
let from = if *path == a.path { a.off } else { 0 };
|
||||
let to = if *path == b.path { b.off } else { p.len() };
|
||||
if p.run_ranges().any(|(r, c)| r.end > from && r.start < to && c.del.is_none()) {
|
||||
return true;
|
||||
}
|
||||
if *path != b.path && has_next_para(s, a.story, path) && p.mark.del.is_none() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// What one tracked deletion did.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub(crate) struct TrackDeleteEffect {
|
||||
pub at: Pos,
|
||||
/// At least one run was removed/marked deleted, or a paragraph mark was removed/marked.
|
||||
pub changed: bool,
|
||||
}
|
||||
|
||||
/// Tracked deletion: own insertions are removed, other text is marked deleted.
|
||||
///
|
||||
/// Preflights first: an all-already-deleted range does nothing and creates no revision record.
|
||||
pub(crate) fn track_delete(s: &mut Session, a: &Pos, b: &Pos) -> Result<Pos, CmdError> {
|
||||
if !track_delete_would_change(s, a, b) {
|
||||
return Ok(a.clone());
|
||||
}
|
||||
let rid = new_revision(s, RevisionKind::Delete);
|
||||
track_delete_as(s, a, b, rid)
|
||||
track_delete_as(s, a, b, rid).map(|e| e.at)
|
||||
}
|
||||
|
||||
/// [`track_delete`] marking the deleted text with revision `rid`.
|
||||
pub(crate) fn track_delete_as(s: &mut Session, a: &Pos, b: &Pos, rid: u32) -> Result<Pos, CmdError> {
|
||||
pub(crate) fn track_delete_as(s: &mut Session, a: &Pos, b: &Pos, rid: u32) -> Result<TrackDeleteEffect, CmdError> {
|
||||
let author = s.author.clone();
|
||||
let mut changed = false;
|
||||
// Remove text this author inserted (it never existed for the reader); mark the rest.
|
||||
let paths = s.doc.paths_between(a, b);
|
||||
for path in paths.iter().rev() {
|
||||
@@ -248,6 +307,7 @@ pub(crate) fn track_delete_as(s: &mut Session, a: &Pos, b: &Pos, rid: u32) -> Re
|
||||
} else {
|
||||
para.format(x, y, &|c| c.del = Some(rid))?;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
// The paragraph mark is inside the range unless this is its last paragraph. Like text,
|
||||
// a mark this author inserted is removed (the paragraphs join again), any other is
|
||||
@@ -264,14 +324,27 @@ pub(crate) fn track_delete_as(s: &mut Session, a: &Pos, b: &Pos, rid: u32) -> Re
|
||||
p.mark.del = Some(rid);
|
||||
p.touch();
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(a.clone())
|
||||
Ok(TrackDeleteEffect { at: a.clone(), changed })
|
||||
}
|
||||
|
||||
/// What one text insertion/deletion commit changed, for IME outcome classification.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub(crate) struct InsertEffect {
|
||||
pub content_changed: bool,
|
||||
pub state_changed: bool,
|
||||
}
|
||||
|
||||
/// Insert typed text at the caret (replacing the selection), with pending formatting and track changes.
|
||||
pub fn type_text(s: &mut Session, text: &str) -> Result<(), CmdError> {
|
||||
insert_text(s, text, true).map(|_| ())
|
||||
}
|
||||
|
||||
/// [`type_text`] with the effect the insertion had.
|
||||
pub(crate) fn type_text_effect(s: &mut Session, text: &str) -> Result<InsertEffect, CmdError> {
|
||||
insert_text(s, text, true)
|
||||
}
|
||||
|
||||
@@ -279,30 +352,38 @@ pub fn type_text(s: &mut Session, text: &str) -> Result<(), CmdError> {
|
||||
/// being transferred, not Enter key presses: an empty list item between them stays (Enter on
|
||||
/// one would end the list).
|
||||
pub fn paste_text(s: &mut Session, text: &str) -> Result<(), CmdError> {
|
||||
insert_text(s, text, false)
|
||||
insert_text(s, text, false).map(|_| ())
|
||||
}
|
||||
|
||||
fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError> {
|
||||
/// Inserts typed or pasted text at the caret: resolves field ends and content-control placeholders,
|
||||
/// deletes the selection, applies tracked-change insertions, and splits paragraphs at every typed
|
||||
/// line break or pasted paragraph separator. A pasted separator keeps the list semantics of pasted
|
||||
/// text; a one-line control suppresses typed breaks only. It gives an otherwise undirected paragraph
|
||||
/// right-to-left direction when its first strong letter is RTL, and returns whether content or state
|
||||
/// changed.
|
||||
fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<InsertEffect, CmdError> {
|
||||
let mut props = s.typing_props();
|
||||
if s.sel.is_collapsed() {
|
||||
s.sel = crate::Selection::caret(s.doc.past_field_ends(&s.sel.focus));
|
||||
}
|
||||
// Content controls: a placeholder is replaced, locked ones refuse.
|
||||
if let Some(p) = controls::prepare_edit(s)? {
|
||||
let prepared = controls::prepare_edit(s)?;
|
||||
let placeholder_changed = prepared.is_some();
|
||||
if let Some(p) = prepared {
|
||||
props = p;
|
||||
}
|
||||
// Pasted text is text: pending properties left by AutoCorrect don't give it a symbol's font (#2036).
|
||||
if !enter {
|
||||
props = props.without_symbol();
|
||||
}
|
||||
delete_selection(s)?;
|
||||
let deletion = delete_selection_effect(s)?;
|
||||
// Typed text is new: never deleted, and not a formatting change of the text around it.
|
||||
props.fmt_change = None;
|
||||
// Its direction comes from its own letters, as on screen, not from the run it's typed into:
|
||||
// Latin typed into reopened Hebrew must not be saved as right-to-left (#1543). The DOCX
|
||||
// writer marks the right-to-left pieces itself.
|
||||
props.rtl = None;
|
||||
if s.doc.settings.track_changes {
|
||||
if s.doc.settings.track_changes && !text.is_empty() {
|
||||
props.ins = Some(new_revision(s, RevisionKind::Insert));
|
||||
props.del = None;
|
||||
} else {
|
||||
@@ -311,6 +392,7 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError>
|
||||
}
|
||||
let mut at = s.sel.focus.clone();
|
||||
let mut first = true;
|
||||
let mut inserted = false;
|
||||
// Typed line breaks are the Enter key between the lines: in a one-line control it does
|
||||
// nothing, so the lines run together and the control stays one paragraph (pasted text keeps
|
||||
// its breaks, as before).
|
||||
@@ -318,10 +400,12 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError>
|
||||
for part in text.split(['\r', '\u{2029}']) {
|
||||
if !first && !no_breaks {
|
||||
at = split_para_as(s, &at, enter)?;
|
||||
inserted = true;
|
||||
}
|
||||
first = false;
|
||||
let part = part.replace('\u{000B}', "\n");
|
||||
if !part.is_empty() {
|
||||
inserted = true;
|
||||
let starts_para = direction_unset(s, &at);
|
||||
at = s.doc.insert_text(&at, &part, &props)?;
|
||||
// Typing Arabic, Hebrew… as the first letters of a paragraph whose direction nothing
|
||||
@@ -338,7 +422,8 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError>
|
||||
}
|
||||
s.sel = crate::Selection::caret(at);
|
||||
s.goal_x = None;
|
||||
Ok(())
|
||||
let content_changed = deletion.content_changed || inserted;
|
||||
Ok(InsertEffect { content_changed, state_changed: deletion.state_changed || content_changed || placeholder_changed })
|
||||
}
|
||||
|
||||
/// Is the paragraph at `at` still without letters (empty, or only spaces, digits and
|
||||
|
||||
@@ -58,7 +58,7 @@ pub fn cut_tracked(s: &mut Session) -> Result<bool, CmdError> {
|
||||
}
|
||||
s.doc.revisions.push(Revision::new(RevisionKind::Delete, s.author.clone(), now_iso()));
|
||||
let rid = (s.doc.revisions.len() - 1) as u32;
|
||||
let at = track_delete_as(s, &a, &b, rid)?;
|
||||
let at = track_delete_as(s, &a, &b, rid)?.at;
|
||||
s.sel = Selection::caret(at);
|
||||
// Only the author's own insertions were cut: they are gone, not moved.
|
||||
if in_use(s, rid) {
|
||||
|
||||
@@ -1396,27 +1396,13 @@ fn clear_background(rgba: &mut image::RgbaImage, tol: f32) -> usize {
|
||||
!seen.get(at(x, y)).copied().unwrap_or(true) && rgba.get_pixel_checked(x, y).is_some_and(|p| close(p.0))
|
||||
};
|
||||
let mut seen = vec![false; (w as usize) * (h as usize)];
|
||||
let mut stack: Vec<(u32, u32)> = Vec::new();
|
||||
for x in 0..w {
|
||||
stack.push((x, 0));
|
||||
stack.push((x, h - 1));
|
||||
}
|
||||
for y in 0..h {
|
||||
stack.push((0, y));
|
||||
stack.push((w - 1, y));
|
||||
}
|
||||
let mut stack = border_pixels(w, h);
|
||||
let mut most = stack.len();
|
||||
while let Some((x, y)) = stack.pop() {
|
||||
if !open(rgba, &seen, x, y) {
|
||||
continue;
|
||||
}
|
||||
let (mut x0, mut x1) = (x, x);
|
||||
while x0 > 0 && open(rgba, &seen, x0 - 1, y) {
|
||||
x0 -= 1;
|
||||
}
|
||||
while x1 + 1 < w && open(rgba, &seen, x1 + 1, y) {
|
||||
x1 += 1;
|
||||
}
|
||||
let (x0, x1) = run_span(x, y, w, |xi, yi| open(rgba, &seen, xi, yi));
|
||||
for xi in x0..=x1 {
|
||||
if let Some(s) = seen.get_mut(at(xi, y)) {
|
||||
*s = true;
|
||||
@@ -1426,23 +1412,54 @@ fn clear_background(rgba: &mut image::RgbaImage, tol: f32) -> usize {
|
||||
}
|
||||
}
|
||||
for ny in [y.checked_sub(1), y.checked_add(1).filter(|&n| n < h)].into_iter().flatten() {
|
||||
let mut xi = x0;
|
||||
while xi <= x1 {
|
||||
if open(rgba, &seen, xi, ny) {
|
||||
stack.push((xi, ny));
|
||||
while xi <= x1 && open(rgba, &seen, xi, ny) {
|
||||
xi += 1;
|
||||
}
|
||||
} else {
|
||||
xi += 1;
|
||||
}
|
||||
}
|
||||
push_open_runs(&mut stack, x0, x1, ny, |xi, yi| open(rgba, &seen, xi, yi));
|
||||
}
|
||||
most = most.max(stack.len());
|
||||
}
|
||||
most
|
||||
}
|
||||
|
||||
/// Every pixel on the image's edge, the flood-fill seeds.
|
||||
fn border_pixels(w: u32, h: u32) -> Vec<(u32, u32)> {
|
||||
let mut stack = Vec::new();
|
||||
for x in 0..w {
|
||||
stack.push((x, 0));
|
||||
stack.push((x, h - 1));
|
||||
}
|
||||
for y in 0..h {
|
||||
stack.push((0, y));
|
||||
stack.push((w - 1, y));
|
||||
}
|
||||
stack
|
||||
}
|
||||
|
||||
/// The horizontal run of open pixels around `(x, y)`, both ends included.
|
||||
fn run_span(x: u32, y: u32, w: u32, open: impl Fn(u32, u32) -> bool) -> (u32, u32) {
|
||||
let (mut x0, mut x1) = (x, x);
|
||||
while x0 > 0 && open(x0 - 1, y) {
|
||||
x0 -= 1;
|
||||
}
|
||||
while x1 + 1 < w && open(x1 + 1, y) {
|
||||
x1 += 1;
|
||||
}
|
||||
(x0, x1)
|
||||
}
|
||||
|
||||
/// Queues the first pixel of each open run in row `ny` between `x0` and `x1`.
|
||||
fn push_open_runs(stack: &mut Vec<(u32, u32)>, x0: u32, x1: u32, ny: u32, open: impl Fn(u32, u32) -> bool) {
|
||||
let mut xi = x0;
|
||||
while xi <= x1 {
|
||||
if open(xi, ny) {
|
||||
stack.push((xi, ny));
|
||||
while xi <= x1 && open(xi, ny) {
|
||||
xi += 1;
|
||||
}
|
||||
} else {
|
||||
xi += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn frame(img: image::DynamicImage, border: u32, color: [u8; 4]) -> Result<image::DynamicImage, CmdError> {
|
||||
let (w, h) = grown(img.width(), img.height(), border).ok_or_else(too_large)?;
|
||||
let mut out = canvas(w, h, color)?;
|
||||
|
||||
@@ -263,6 +263,8 @@ fn folder_setting(key: &str, f: &str, local_only: bool) -> Result<Option<String>
|
||||
if !path.is_absolute() {
|
||||
return Err(CmdError::Params(format!("`{key}`: {f:?} isn't a full path")));
|
||||
}
|
||||
// Its files are read from now on, and on every start: only from inside the read root (#3737).
|
||||
crate::file_access::check_read(&path.to_string_lossy()).map_err(CmdError::Failed)?;
|
||||
if !path.is_dir() {
|
||||
return Err(CmdError::Failed(format!("no folder at {}", path.display())));
|
||||
}
|
||||
|
||||
@@ -460,6 +460,51 @@ pub(crate) fn move_shown(s: &mut Session, up: bool) -> Option<CmdResult> {
|
||||
Some(sel_result(s))
|
||||
}
|
||||
|
||||
/// Keeps collapsed headings collapsed after an edit: each one moves to the nearest heading with the
|
||||
/// same text that is still free.
|
||||
fn remap_collapsed_headings(s: &mut Session) {
|
||||
let hashes: Vec<Option<u64>> = (0..s.doc.body.len()).map(|i| text_hash(&s.doc, i)).collect();
|
||||
let levels = ol::levels(&s.doc);
|
||||
// The headings not yet taken, by text hash.
|
||||
let mut by_hash: HashMap<u64, BTreeSet<usize>> = HashMap::new();
|
||||
for (j, h) in hashes.iter().enumerate() {
|
||||
if let Some(h) = h
|
||||
&& levels.get(j).is_some_and(|l| *l < BODY)
|
||||
{
|
||||
by_hash.entry(*h).or_default().insert(j);
|
||||
}
|
||||
}
|
||||
let mut used = BTreeSet::new();
|
||||
let mut out = Vec::new();
|
||||
for (i, h) in std::mem::take(&mut s.view.outline.collapsed) {
|
||||
let free = |j: usize, used: &BTreeSet<usize>| levels.get(j).is_some_and(|l| *l < BODY) && !used.contains(&j);
|
||||
let same = |j: usize| hashes.get(j).copied().flatten() == Some(h);
|
||||
let found = if free(i, &used) && same(i) {
|
||||
Some(i)
|
||||
} else {
|
||||
// The nearest free heading with the same text (the earlier one on a tie).
|
||||
let nearest = by_hash.get(&h).and_then(|set| {
|
||||
let below = set.range(..=i).next_back().copied();
|
||||
let above = set.range(i..).next().copied();
|
||||
match (below, above) {
|
||||
(Some(b), Some(a)) => Some(if a.abs_diff(i) < b.abs_diff(i) { a } else { b }),
|
||||
(b, a) => b.or(a),
|
||||
}
|
||||
});
|
||||
nearest.or_else(|| free(i, &used).then_some(i))
|
||||
};
|
||||
if let Some(j) = found {
|
||||
used.insert(j);
|
||||
if let Some(set) = hashes.get(j).copied().flatten().and_then(|hj| by_hash.get_mut(&hj)) {
|
||||
set.remove(&j);
|
||||
}
|
||||
out.push((j, hashes.get(j).copied().flatten().unwrap_or(h)));
|
||||
}
|
||||
}
|
||||
out.sort_unstable();
|
||||
s.view.outline.collapsed = out;
|
||||
}
|
||||
|
||||
/// After every command: collapsed headings are found again where edits moved them (by their
|
||||
/// text; one whose text changed in place stays collapsed), and in Outline view a caret the
|
||||
/// outline doesn't show (in collapsed text, or past a body paragraph's first line) moves on to
|
||||
@@ -467,46 +512,7 @@ pub(crate) fn move_shown(s: &mut Session, up: bool) -> Option<CmdResult> {
|
||||
/// `changed` says whether the document changed (its revision moved); if not, nothing moved.
|
||||
pub(crate) fn after_command(s: &mut Session, before: &Selection, changed: bool) {
|
||||
if changed && !s.view.outline.collapsed.is_empty() {
|
||||
let hashes: Vec<Option<u64>> = (0..s.doc.body.len()).map(|i| text_hash(&s.doc, i)).collect();
|
||||
let levels = ol::levels(&s.doc);
|
||||
// The headings not yet taken, by text hash.
|
||||
let mut by_hash: HashMap<u64, BTreeSet<usize>> = HashMap::new();
|
||||
for (j, h) in hashes.iter().enumerate() {
|
||||
if let Some(h) = h
|
||||
&& levels.get(j).is_some_and(|l| *l < BODY)
|
||||
{
|
||||
by_hash.entry(*h).or_default().insert(j);
|
||||
}
|
||||
}
|
||||
let mut used = BTreeSet::new();
|
||||
let mut out = Vec::new();
|
||||
for (i, h) in std::mem::take(&mut s.view.outline.collapsed) {
|
||||
let free = |j: usize, used: &BTreeSet<usize>| levels.get(j).is_some_and(|l| *l < BODY) && !used.contains(&j);
|
||||
let same = |j: usize| hashes.get(j).copied().flatten() == Some(h);
|
||||
let found = if free(i, &used) && same(i) {
|
||||
Some(i)
|
||||
} else {
|
||||
// The nearest free heading with the same text (the earlier one on a tie).
|
||||
let nearest = by_hash.get(&h).and_then(|set| {
|
||||
let below = set.range(..=i).next_back().copied();
|
||||
let above = set.range(i..).next().copied();
|
||||
match (below, above) {
|
||||
(Some(b), Some(a)) => Some(if a.abs_diff(i) < b.abs_diff(i) { a } else { b }),
|
||||
(b, a) => b.or(a),
|
||||
}
|
||||
});
|
||||
nearest.or_else(|| free(i, &used).then_some(i))
|
||||
};
|
||||
if let Some(j) = found {
|
||||
used.insert(j);
|
||||
if let Some(set) = hashes.get(j).copied().flatten().and_then(|hj| by_hash.get_mut(&hj)) {
|
||||
set.remove(&j);
|
||||
}
|
||||
out.push((j, hashes.get(j).copied().flatten().unwrap_or(h)));
|
||||
}
|
||||
}
|
||||
out.sort_unstable();
|
||||
s.view.outline.collapsed = out;
|
||||
remap_collapsed_headings(s);
|
||||
}
|
||||
if s.view.mode != ViewMode::Outline || !s.sel.is_collapsed() || s.sel.focus.story != StoryRef::Body {
|
||||
return;
|
||||
|
||||
@@ -406,6 +406,8 @@ fn check_formatting(s: &Session, id: &str, params: &Value) -> Result<(), CmdErro
|
||||
| "table.modifyStyle"
|
||||
| "table.deleteStyle"
|
||||
| "tools.updateStyles"
|
||||
// Copies style definitions in from another file, locked flags included (#3692).
|
||||
| "styles.copyFrom"
|
||||
)
|
||||
// Attach Template updates the styles from it when asked (`linkStyles` is only a setting).
|
||||
|| (id == "tools.attachTemplate" && p::bool(params, "update") == Some(true));
|
||||
@@ -817,7 +819,8 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Formatting limited to styles: attaching a template refuses to update the styles from it
|
||||
/// (it would bypass the Update Styles block); attaching without updating still works.
|
||||
/// (it would bypass the Update Styles block), nor can Copy Styles; attaching without
|
||||
/// updating still works.
|
||||
#[test]
|
||||
fn formatting_restriction_refuses_attach_template_update() {
|
||||
let mut t = Document::from_text("Template.");
|
||||
@@ -831,6 +834,12 @@ mod tests {
|
||||
let e = s.run("tools.attachTemplate", &json!({"path": "Report.dotx", "update": true, "data": data})).unwrap_err().to_string();
|
||||
assert!(e.contains("formatting is limited"), "{e}");
|
||||
assert_eq!(s.doc, before);
|
||||
// Copying styles in replaces their definitions, locked flag included (#3692).
|
||||
s.doc.styles.styles.iter_mut().filter(|st| st.id == "Heading1").for_each(|st| st.locked = true);
|
||||
let before = s.doc.clone();
|
||||
let e = s.run("styles.copyFrom", &json!({"path": "Report.dotx", "styles": ["Heading 1"], "data": data})).unwrap_err().to_string();
|
||||
assert!(e.contains("formatting is limited"), "{e}");
|
||||
assert_eq!(s.doc, before);
|
||||
s.run("tools.attachTemplate", &json!({"path": "Report.dotx"})).unwrap();
|
||||
assert_eq!(s.doc.settings.attached_template.as_deref(), Some("Report.dotx"));
|
||||
}
|
||||
|
||||
@@ -606,6 +606,44 @@ fn resolve_sel_then(s: &mut Session, v: &Value, accept: bool) -> CmdResult {
|
||||
resolve_sel(s, accept, p::bool(v, "next") == Some(true))
|
||||
}
|
||||
|
||||
/// The span of the tracked change around `off` in `p`: its run, joined across the comment anchors
|
||||
/// that split one revision's text into several runs.
|
||||
fn revision_span(p: &Paragraph, off: usize) -> Option<std::ops::Range<usize>> {
|
||||
let runs: Vec<_> = p.run_ranges().collect();
|
||||
let i = runs.iter().position(|(r, c)| r.start <= off && off <= r.end && (c.ins.is_some() || c.del.is_some() || c.fmt_change.is_some()))?;
|
||||
let (mut start, mut end) = (runs[i].0.start, runs[i].0.end);
|
||||
let key = (runs[i].1.ins, runs[i].1.del);
|
||||
if key == (None, None) {
|
||||
return Some(start..end);
|
||||
}
|
||||
// Comment anchors split a revision's text into several runs; the revision goes on past them.
|
||||
let is_anchor = |k: usize| matches!(p.object_at(runs[k].0.start), Some(InlineObject::CommentStart { .. } | InlineObject::CommentEnd { .. }));
|
||||
let same = |k: usize| (runs[k].1.ins, runs[k].1.del) == key;
|
||||
let mut k = i;
|
||||
while k > 0 {
|
||||
if same(k - 1) {
|
||||
k -= 1;
|
||||
} else if is_anchor(k - 1) && k > 1 && same(k - 2) {
|
||||
k -= 2;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
start = runs[k].0.start;
|
||||
}
|
||||
let mut k = i;
|
||||
while k + 1 < runs.len() {
|
||||
if same(k + 1) {
|
||||
k += 1;
|
||||
} else if is_anchor(k + 1) && k + 2 < runs.len() && same(k + 2) {
|
||||
k += 2;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
end = runs[k].0.end;
|
||||
}
|
||||
Some(start..end)
|
||||
}
|
||||
|
||||
fn resolve_sel(s: &mut Session, accept: bool, next: bool) -> CmdResult {
|
||||
let (a, b) = s.sel.ordered();
|
||||
// A caret right before a tracked paragraph mark (and no tracked text around it): that mark.
|
||||
@@ -616,44 +654,7 @@ fn resolve_sel(s: &mut Session, accept: bool, next: bool) -> CmdResult {
|
||||
let (a, b) = if a == b {
|
||||
// The change at the caret: the run around it, else the paragraph mark right after it,
|
||||
// else the paragraph's formatting, else the table cell's.
|
||||
let found = s.doc.para_at(&a).and_then(|p| {
|
||||
let runs: Vec<_> = p.run_ranges().collect();
|
||||
let i = runs
|
||||
.iter()
|
||||
.position(|(r, c)| r.start <= a.off && a.off <= r.end && (c.ins.is_some() || c.del.is_some() || c.fmt_change.is_some()))?;
|
||||
let (mut start, mut end) = (runs[i].0.start, runs[i].0.end);
|
||||
let key = (runs[i].1.ins, runs[i].1.del);
|
||||
if key == (None, None) {
|
||||
return Some(start..end);
|
||||
}
|
||||
// Comment anchors split a revision's text into several runs; the revision goes on past them.
|
||||
let is_anchor =
|
||||
|k: usize| matches!(p.object_at(runs[k].0.start), Some(InlineObject::CommentStart { .. } | InlineObject::CommentEnd { .. }));
|
||||
let same = |k: usize| (runs[k].1.ins, runs[k].1.del) == key;
|
||||
let mut k = i;
|
||||
while k > 0 {
|
||||
if same(k - 1) {
|
||||
k -= 1;
|
||||
} else if is_anchor(k - 1) && k > 1 && same(k - 2) {
|
||||
k -= 2;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
start = runs[k].0.start;
|
||||
}
|
||||
let mut k = i;
|
||||
while k + 1 < runs.len() {
|
||||
if same(k + 1) {
|
||||
k += 1;
|
||||
} else if is_anchor(k + 1) && k + 2 < runs.len() && same(k + 2) {
|
||||
k += 2;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
end = runs[k].0.end;
|
||||
}
|
||||
Some(start..end)
|
||||
});
|
||||
let found = s.doc.para_at(&a).and_then(|p| revision_span(p, a.off));
|
||||
let para_fmt = s.doc.para_at(&a).is_some_and(|p| p.props.fmt_change.is_some() || p.props.num_change.is_some());
|
||||
let mark_fmt = s.doc.para_at(&a).is_some_and(|p| a.off == p.len() && p.mark.fmt_change.is_some());
|
||||
match found {
|
||||
|
||||
@@ -35,7 +35,8 @@ fn read_aloud(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if let Some(b) = p::bool(v, "skipCitations") {
|
||||
s.read_aloud.skip_citations = b;
|
||||
}
|
||||
if s.read_aloud.state() != State::Stopped {
|
||||
// A reading of this document pauses or carries on; one of another document gives way.
|
||||
if s.read_aloud.state() != State::Stopped && s.read_aloud.document == Some(s.document_id()) {
|
||||
s.read_aloud.play_pause();
|
||||
return status(s, v);
|
||||
}
|
||||
@@ -54,12 +55,13 @@ fn read_aloud(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if queue.is_empty() {
|
||||
return Err(CmdError::Failed("there's nothing to read".into()));
|
||||
}
|
||||
s.read_aloud.document = Some(s.document_id());
|
||||
s.read_aloud.start(queue);
|
||||
status(s, v)
|
||||
}
|
||||
|
||||
fn play_pause(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if s.read_aloud.progress().1 == 0 {
|
||||
if s.read_aloud.progress().1 == 0 || s.read_aloud.document != Some(s.document_id()) {
|
||||
return read_aloud(s, v);
|
||||
}
|
||||
s.read_aloud.play_pause();
|
||||
@@ -92,7 +94,7 @@ fn skip_citations(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let on = p::bool(v, "value").unwrap_or(!s.read_aloud.skip_citations);
|
||||
if on != s.read_aloud.skip_citations {
|
||||
s.read_aloud.skip_citations = on;
|
||||
if let Some(cur) = s.read_aloud.current() {
|
||||
if let Some(cur) = s.read_aloud.current().filter(|_| s.read_aloud.document == Some(s.document_id())) {
|
||||
let paused = s.read_aloud.state() == State::Paused;
|
||||
let queue = speech::sentences(&s.doc, &cur.start, None, on);
|
||||
if !queue.is_empty() {
|
||||
|
||||
@@ -185,17 +185,22 @@ fn load_other(name: &str, data: Option<&str>, base: Option<&Path>) -> Result<Doc
|
||||
if name.len() > MAX_PATH {
|
||||
return Err(CmdError::Params("`path` is too long".into()));
|
||||
}
|
||||
let local = local_path(name, base).ok_or_else(|| CmdError::Failed(format!("`{name}` is not a local file")))?;
|
||||
let ext = local.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
// With the bytes given nothing is read, so the name only gives the format: no path is
|
||||
// resolved, and a document on a share (or a `\\?\` path) can still use a relative name.
|
||||
let file = match data {
|
||||
Some(_) => PathBuf::from(name),
|
||||
None => local_path(name, base).ok_or_else(|| CmdError::Failed(format!("`{name}` is not a local file")))?,
|
||||
};
|
||||
let ext = file.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
if matches!(ext.as_str(), "" | "txt" | "text") {
|
||||
return Err(CmdError::Params(format!("`{name}` is not a document or template with styles")));
|
||||
}
|
||||
match data {
|
||||
Some(d) => {
|
||||
let bytes = super::insert::base64_decode(d).ok_or_else(|| CmdError::Params("bad base64".into()))?;
|
||||
crate::io::open_bytes(&local.to_string_lossy(), &bytes).map_err(CmdError::Failed)
|
||||
crate::io::open_bytes(name, &bytes).map_err(CmdError::Failed)
|
||||
}
|
||||
None => crate::io::open_path(&local).map_err(CmdError::Failed),
|
||||
None => crate::io::open_path(&file).map_err(CmdError::Failed),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,18 +236,34 @@ pub fn local_path(target: &str, base: Option<&Path>) -> Option<PathBuf> {
|
||||
return None;
|
||||
}
|
||||
let p = PathBuf::from(&path);
|
||||
if p.is_relative()
|
||||
&& !is_windows_absolute(&path)
|
||||
&& let Some(dir) = base.and_then(Path::parent)
|
||||
{
|
||||
return Some(dir.join(p));
|
||||
}
|
||||
Some(p)
|
||||
let p = match base.and_then(Path::parent) {
|
||||
Some(dir) if p.is_relative() && !is_windows_absolute(&path) => dir.join(p),
|
||||
_ => p,
|
||||
};
|
||||
// Checked again once joined: the document's own folder may be on another machine (#3698).
|
||||
(!is_network_path(&p.to_string_lossy())).then_some(p)
|
||||
}
|
||||
|
||||
/// Does `p` name another machine or a device, whatever separators it mixes: `\\host\share`,
|
||||
/// `//host`, `\\?\UNC\…`, `\\.\UNC\…`, other devices, and the NT namespace `\??\…`. The
|
||||
/// verbatim, device and NT prefixes are local when a drive letter follows (`\\?\C:\…`,
|
||||
/// `\\.\C:\…`, `\??\C:\…`, #3698). Purely lexical, so it is the same on every platform.
|
||||
pub(crate) fn is_network_path(p: &str) -> bool {
|
||||
let sep = |c: Option<&u8>| matches!(c, Some(b'\\' | b'/'));
|
||||
let b = p.as_bytes();
|
||||
matches!((b.first(), b.get(1)), (Some(b'\\' | b'/'), Some(b'\\' | b'/')))
|
||||
// `\\?\`, `\\.\` or `\??\`, then `X:` and the end or a separator.
|
||||
let prefix = sep(b.first())
|
||||
&& sep(b.get(3))
|
||||
&& match (b.get(1), b.get(2)) {
|
||||
(Some(b'?'), Some(b'?')) => true,
|
||||
(c, Some(b'?' | b'.')) => sep(c),
|
||||
_ => false,
|
||||
};
|
||||
let drive = b.get(4).is_some_and(u8::is_ascii_alphabetic) && b.get(5) == Some(&b':') && (b.len() == 6 || sep(b.get(6)));
|
||||
if prefix {
|
||||
return !drive;
|
||||
}
|
||||
sep(b.first()) && sep(b.get(1))
|
||||
}
|
||||
|
||||
fn is_windows_absolute(p: &str) -> bool {
|
||||
@@ -489,6 +510,29 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(path.parent().unwrap());
|
||||
}
|
||||
|
||||
/// Ultrareview of #3698: with the template's bytes given (`data`) nothing is read from a path,
|
||||
/// so a document opened from a share or a `\\?\` path can still attach, update and organise
|
||||
/// styles from a template named relative to it. Without the bytes, the share is never read.
|
||||
#[test]
|
||||
fn template_bytes_need_no_local_path() {
|
||||
let data = crate::cmd::insert::base64_encode(&wordcraft_docx::write(&template()).unwrap());
|
||||
for doc_path in ["//fileserver/share/Letter.docx", "//?/C:/docs/Letter.docx"] {
|
||||
let mut s = Session::new(Document::new());
|
||||
s.path = Some(PathBuf::from(doc_path));
|
||||
s.run("tools.attachTemplate", &json!({"path": "Report.dotx", "data": data, "update": true})).unwrap();
|
||||
assert_eq!(s.doc.styles.get("Heading1").unwrap().chr.size, Some(31.0), "{doc_path}");
|
||||
s.run("tools.updateStyles", &json!({"path": "Report.dotx", "data": data})).unwrap();
|
||||
let listed = s.run("styles.organizer", &json!({"path": "Report.dotx", "data": data})).unwrap();
|
||||
assert!(listed["file"]["styles"].as_array().unwrap().iter().any(|x| x["name"] == "Callout"), "{doc_path}");
|
||||
s.run("styles.copyFrom", &json!({"path": "Report.dotx", "data": data, "styles": ["Callout"]})).unwrap();
|
||||
assert!(s.doc.styles.get("Callout").is_some(), "{doc_path}");
|
||||
}
|
||||
let mut s = Session::new(Document::new());
|
||||
s.path = Some(PathBuf::from("//fileserver/share/Letter.docx"));
|
||||
let err = s.run("tools.updateStyles", &json!({"path": "Report.dotx"})).unwrap_err().to_string();
|
||||
assert!(err.contains("not a local file"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_references_become_local_paths() {
|
||||
assert_eq!(local_path("file:///C:/Users/A%20B/T.dotx", None), Some(PathBuf::from("C:/Users/A B/T.dotx")));
|
||||
@@ -504,9 +548,33 @@ mod tests {
|
||||
"file://evil.example/share/T.dotx",
|
||||
"file:////evil.example/share/T.dotx",
|
||||
"FILE://Evil.Example/T.dotx",
|
||||
// The NT namespace (#3698), with any separators.
|
||||
"\\??\\UNC\\evil.example\\share\\T.dotx",
|
||||
"/??/UNC/evil.example/share/T.dotx",
|
||||
"\\??/UNC\\evil.example/share/T.dotx",
|
||||
"\\??\\GLOBALROOT\\Device\\Mup\\evil.example\\share\\T.dotx",
|
||||
"\\\\.\\UNC\\evil.example\\share\\T.dotx",
|
||||
] {
|
||||
assert_eq!(local_path(remote, Some(Path::new("/docs/Letter.docx"))), None, "{remote}");
|
||||
assert_eq!(local_path(remote, None), None, "{remote} without a folder");
|
||||
}
|
||||
// A relative reference from a document whose folder is on another machine (#3698).
|
||||
assert_eq!(local_path("T.dotx", Some(Path::new("//evil.example/share/Letter.docx"))), None);
|
||||
assert_eq!(local_path("file:T.dotx", Some(Path::new("//evil.example/share/Letter.docx"))), None);
|
||||
// A drive in the NT namespace is local.
|
||||
assert!(!is_network_path("\\??\\C:\\Templates\\T.dotx"));
|
||||
assert!(!is_network_path("\\??\\C:"));
|
||||
assert!(is_network_path("\\??\\C:evil"));
|
||||
// Verbatim and device paths with a drive are local; with `UNC` or anything else they aren't.
|
||||
for local in ["\\\\?\\C:\\Templates\\T.dotx", "\\\\.\\C:\\Templates\\T.dotx", "//?/C:/Templates/T.dotx", "\\\\?\\C:"] {
|
||||
assert!(!is_network_path(local), "{local}");
|
||||
}
|
||||
for remote in
|
||||
["\\\\?\\UNC\\host\\share\\T.dotx", "//./UNC/host/share/T.dotx", "\\\\host\\share\\T.dotx", "\\\\?\\C:evil", "\\\\.\\PhysicalDrive0"]
|
||||
{
|
||||
assert!(is_network_path(remote), "{remote}");
|
||||
}
|
||||
assert_eq!(local_path("T.dotx", Some(Path::new("//?/C:/docs/Letter.docx"))), Some(PathBuf::from("//?/C:/docs/T.dotx")));
|
||||
assert_eq!(percent_decode("100%"), "100%");
|
||||
assert_eq!(percent_decode("%zz%4"), "%zz%4");
|
||||
}
|
||||
|
||||
+187
-43
@@ -5,12 +5,13 @@ use wordcraft_doc::para::{COLUMN_BREAK, NB_HYPHEN, NBSP, OBJ, PAGE_BREAK, SOFT_H
|
||||
use wordcraft_doc::props::{Border, BorderStyle, NumRef};
|
||||
use wordcraft_doc::{Block, ListKind, Path, Pos, StoryRef, Table};
|
||||
|
||||
use super::{delete_selection, non_mac, sel_result, split_para, type_text};
|
||||
use super::{delete_selection, delete_selection_effect, non_mac, sel_result, split_para, type_text, type_text_effect};
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
|
||||
|
||||
/// The text-entry commands: typing, Enter, breaks, Tab, deleting and typed symbols.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("text.insert", "Type Text", "Editing", insert).params(r#"{"text": string}"#),
|
||||
CommandSpec::new("text.insert", "Type Text", "Editing", insert).params(r#"{"text": string, "raw"?: bool, "paragraphs"?: bool} (paragraphs: each line a paragraph, one undo step, at most 10000 lines)"#),
|
||||
CommandSpec::new("text.newParagraph", "New Paragraph", "Editing", new_paragraph).key("Enter"),
|
||||
CommandSpec::new("text.lineBreak", "Line Break", "Editing", |s, _| ins_char(s, '\n')).key("Shift+Enter"),
|
||||
CommandSpec::new("text.pageBreak", "Page Break", "Insert › Pages", |s, _| ins_char(s, PAGE_BREAK)).key("Mod+Enter"),
|
||||
@@ -96,14 +97,30 @@ fn typing_options(s: &mut Session, text: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `text.insert`: type `text` at the selection as the keyboard does (AutoCorrect and
|
||||
/// AutoFormat unless `raw`), or, with `paragraphs`, line by line with Enter between. A single
|
||||
/// trigger character records the IME outcome, and list, AutoCorrect, dash and double-space
|
||||
/// AutoFormat run as a separate undo step when they rewrite the paragraph.
|
||||
fn insert(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let text = p::req_str(v, "text")?;
|
||||
let raw = p::bool(v, "raw").unwrap_or(false);
|
||||
if p::bool(v, "paragraphs").unwrap_or(false) {
|
||||
return insert_paragraphs(s, text);
|
||||
}
|
||||
typing_options(s, text);
|
||||
let t = if raw { None } else { autoformat(s, text) };
|
||||
type_text(s, t.as_deref().unwrap_or(text))?;
|
||||
let smart_quote = if raw { None } else { autoformat(s, text) };
|
||||
let was_collapsed = s.sel.is_collapsed();
|
||||
let effect = type_text_effect(s, smart_quote.as_deref().unwrap_or(text))?;
|
||||
let mut outcome = crate::ImeEditOutcome {
|
||||
classified: true,
|
||||
content_changed: effect.content_changed,
|
||||
state_changed: effect.state_changed,
|
||||
transformed: smart_quote.is_some(),
|
||||
selection_changed: false,
|
||||
};
|
||||
let trigger = text.chars().count() == 1 && text.chars().all(|c| c == ' ' || ",.;:!?".contains(c));
|
||||
if raw || !trigger {
|
||||
s.set_ime_edit_outcome(outcome);
|
||||
return sel_result(s);
|
||||
}
|
||||
// The paragraph as typed, so an automatic change can be undone on its own.
|
||||
@@ -114,8 +131,22 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if !listed {
|
||||
super::tools::autocorrect(s).inspect_err(|_| s.autocap = None)?;
|
||||
}
|
||||
if text == " " && !listed && s.prefs.autocorrect.enabled && s.prefs.autocorrect.dashes {
|
||||
dash_autoformat(s).inspect_err(|_| s.autocap = None)?;
|
||||
let dashed = text == " "
|
||||
&& !listed
|
||||
&& s.prefs.autocorrect.enabled
|
||||
&& s.prefs.autocorrect.dashes
|
||||
&& dash_autoformat(s).inspect_err(|_| s.autocap = None)?;
|
||||
if text == " " && !listed && was_collapsed && s.prefs.autocorrect.enabled && s.prefs.autocorrect.double_space_period {
|
||||
double_space_period(s)?;
|
||||
}
|
||||
// Compare the paragraph with the one exactly as typed. This is the actual effect of
|
||||
// AutoCorrect (including formatting-only superscripts and links), not an undo label that
|
||||
// may have been left by an earlier step. It also covers the double-space period.
|
||||
let rewritten = typed.as_ref().is_some_and(|(para, _)| s.doc.para_at(&f) != Some(para));
|
||||
if listed || dashed || rewritten {
|
||||
outcome.content_changed = true;
|
||||
outcome.state_changed = true;
|
||||
outcome.transformed = true;
|
||||
}
|
||||
if let Some((para, sel)) = typed
|
||||
&& s.doc.para_at(&f) != Some(¶)
|
||||
@@ -133,6 +164,7 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
}
|
||||
s.autocap.take_if(|c| c.depth == u64::MAX);
|
||||
s.set_ime_edit_outcome(outcome);
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
@@ -187,11 +219,32 @@ fn list_autoformat(s: &mut Session) -> Result<bool, CmdError> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// "word -- word" → en dash, "word--word" → em dash (on the space after).
|
||||
fn dash_autoformat(s: &mut Session) -> Result<(), CmdError> {
|
||||
/// "word" + two spaces → "word. ": the second Space turns the first into a period, after a
|
||||
/// letter, a digit or a closing bracket or quote; not after other punctuation, at the start of a
|
||||
/// paragraph, after a tab, or for a third space (the text before them ends in a period by then).
|
||||
fn double_space_period(s: &mut Session) -> Result<(), CmdError> {
|
||||
let f = s.sel.focus.clone();
|
||||
let Some(para) = s.doc.para_at(&f) else { return Ok(()) };
|
||||
let Some(before) = para.text.get(..f.off) else { return Ok(()) };
|
||||
let Some(word) = before.strip_suffix(" ") else { return Ok(()) };
|
||||
if !word.chars().next_back().is_some_and(|c| c.is_alphanumeric() || ")]}\"'\u{201D}\u{2019}\u{00BB}\u{203A}".contains(c)) {
|
||||
return Ok(());
|
||||
}
|
||||
// The period takes the formatting of the space it replaces.
|
||||
let at = word.len();
|
||||
let p = s.doc.para_mut(f.story, &f.path)?;
|
||||
let props = p.props_at(at + 1).clone().without_symbol();
|
||||
p.delete(at, at + 1)?;
|
||||
p.insert_text(at, ".", &props)?;
|
||||
s.sel = Selection::caret(f);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// "word -- word" → en dash, "word--word" → em dash (on the space after).
|
||||
fn dash_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) };
|
||||
let Some(before) = para.text.get(..f.off) else { return Ok(false) };
|
||||
if before.ends_with(' ')
|
||||
&& let Some(i) = before.trim_end().rfind(" -- ")
|
||||
&& before.trim_end().get(i + 4..).is_some_and(|w| !w.is_empty() && !w.contains(' '))
|
||||
@@ -202,6 +255,7 @@ fn dash_autoformat(s: &mut Session) -> Result<(), CmdError> {
|
||||
p.delete(i + 1, i + 3)?;
|
||||
p.insert_text(i + 1, "\u{2013}", &props)?;
|
||||
s.sel = Selection::caret(Pos { off: f.off + 1, ..f });
|
||||
return Ok(true);
|
||||
} else if before.ends_with(' ')
|
||||
&& let Some(i) = before.trim_end().rfind(" - ")
|
||||
&& !before.trim_end()[i + 3..].contains(' ')
|
||||
@@ -211,6 +265,7 @@ fn dash_autoformat(s: &mut Session) -> Result<(), CmdError> {
|
||||
p.delete(i + 1, i + 2)?;
|
||||
p.insert_text(i + 1, "\u{2013}", &props)?;
|
||||
s.sel = Selection::caret(Pos { off: f.off + 2, ..f });
|
||||
return Ok(true);
|
||||
} else if before.ends_with(' ')
|
||||
&& let Some(i) = before.trim_end().rfind("--")
|
||||
&& i > 0
|
||||
@@ -221,8 +276,9 @@ fn dash_autoformat(s: &mut Session) -> Result<(), CmdError> {
|
||||
p.delete(i, i + 2)?;
|
||||
p.insert_text(i, "\u{2014}", &props)?;
|
||||
s.sel = Selection::caret(Pos { off: f.off + 1, ..f });
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(())
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// An AutoFormat that Enter applies to the paragraph just finished.
|
||||
@@ -663,17 +719,58 @@ fn sample_text(s: &Session) -> Option<String> {
|
||||
Some(text)
|
||||
}
|
||||
|
||||
/// `text.newParagraph`: Enter, unless the selection is in a control that takes no paragraph break.
|
||||
fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
|
||||
// Enter in a one-line plain text control (or a check box or list) does nothing, as in Word.
|
||||
if super::controls::refuses_paragraph(s) {
|
||||
return sel_result(s);
|
||||
}
|
||||
enter(s, true)?;
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Most lines `text.insert` with `paragraphs` types in one call, for every caller (control
|
||||
/// channel, CLI, MCP): each line is an Enter, with its AutoFormat and AutoCorrect, so the work
|
||||
/// grows with the line count. More is refused before anything is typed (#3692).
|
||||
pub const MAX_TYPED_PARAGRAPHS: usize = 10_000;
|
||||
|
||||
/// `text.insert` with `paragraphs`: each line typed as it is (`raw`), and between lines the
|
||||
/// paragraph break Enter makes ([`enter`]: its AutoFormat and AutoCorrect), all in one command, so
|
||||
/// one undo step instead of a command and a whole-document snapshot per line (#3692). Where
|
||||
/// Enter would do nothing (a one-line control, a check box, a list) the text is refused and,
|
||||
/// like any failed command, nothing of it stays. More than [`MAX_TYPED_PARAGRAPHS`] lines are
|
||||
/// refused before anything is typed.
|
||||
fn insert_paragraphs(s: &mut Session, text: &str) -> CmdResult {
|
||||
let text = text.replace("\r\n", "\n");
|
||||
let lines = text.split(['\n', '\r', '\u{2029}']).count();
|
||||
if lines > MAX_TYPED_PARAGRAPHS {
|
||||
return Err(CmdError::Params(format!("text.insert: `text` has {lines} lines; type at most {MAX_TYPED_PARAGRAPHS} paragraphs per call")));
|
||||
}
|
||||
typing_options(s, &text);
|
||||
for (i, line) in text.split(['\n', '\r', '\u{2029}']).enumerate() {
|
||||
if i > 0 {
|
||||
if super::controls::refuses_paragraph(s) {
|
||||
return Err(CmdError::Disabled("text.insert: this content control takes no paragraph breaks".into()));
|
||||
}
|
||||
enter(s, false)?;
|
||||
}
|
||||
if !line.is_empty() {
|
||||
type_text(s, line)?;
|
||||
}
|
||||
}
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
/// Enter's paragraph break once a control has allowed it: `=rand()` sample text, AutoFormat of a
|
||||
/// border or table line, AutoCorrect of the word it ends, else a plain split. With `undo_step`
|
||||
/// an AutoFormat is recorded as its own undo step after the plain Enter, as in Word; without it
|
||||
/// (typed paragraphs, one undo step for all) no extra snapshot is taken.
|
||||
fn enter(s: &mut Session, undo_step: bool) -> Result<(), CmdError> {
|
||||
super::controls::prepare_edit(s)?;
|
||||
if let Some(text) = sample_text(s) {
|
||||
let end = s.sel.focus.clone();
|
||||
s.sel = Selection { anchor: Pos { off: 0, ..end.clone() }, focus: end };
|
||||
type_text(s, &text)?;
|
||||
return sel_result(s);
|
||||
return type_text(s, &text);
|
||||
}
|
||||
let mut at = delete_selection(s)?;
|
||||
let auto = border_line(s, &at)
|
||||
@@ -683,19 +780,24 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
|
||||
if let Some(auto) = auto {
|
||||
// Word: Undo right after reverts only the AutoFormat, so the typed characters come back
|
||||
// and the Enter stays. Record that plain Enter as its own undo step.
|
||||
let (doc, sel) = (s.doc.clone(), s.sel.clone());
|
||||
let new = split_para(s, &at)?;
|
||||
s.sel = Selection::caret(new);
|
||||
let plain_doc = std::mem::replace(&mut s.doc, doc);
|
||||
let plain_sel = std::mem::replace(&mut s.sel, sel);
|
||||
let plain = if undo_step {
|
||||
let (doc, sel) = (s.doc.clone(), s.sel.clone());
|
||||
let new = split_para(s, &at)?;
|
||||
s.sel = Selection::caret(new);
|
||||
Some((std::mem::replace(&mut s.doc, doc), std::mem::replace(&mut s.sel, sel)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match auto {
|
||||
EnterAutoFormat::Border(line) => apply_border_line(s, &at, line)?,
|
||||
EnterAutoFormat::Table(cols) => apply_table_line(s, &at, &cols)?,
|
||||
EnterAutoFormat::Heading(level) => apply_heading_line(s, &at, level)?,
|
||||
}
|
||||
s.goal_x = None;
|
||||
s.push_undo("AutoFormat", plain_doc, plain_sel);
|
||||
return sel_result(s);
|
||||
if let Some((plain_doc, plain_sel)) = plain {
|
||||
s.push_undo("AutoFormat", plain_doc, plain_sel);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
// Enter finishes a word like a space does ("teh" → "the", sentence capitals, links).
|
||||
if s.sel.is_collapsed() && at.off > 0 {
|
||||
@@ -722,7 +824,7 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
|
||||
s.sel = Selection::caret(new);
|
||||
s.goal_x = None;
|
||||
s.push_undo("AutoFormat", plain_doc, plain_sel);
|
||||
return sel_result(s);
|
||||
return Ok(());
|
||||
}
|
||||
super::tools::autocorrect_word(s, true)?;
|
||||
s.autocap.take_if(|c| c.depth == u64::MAX);
|
||||
@@ -731,7 +833,7 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
|
||||
let new = split_para(s, &at)?;
|
||||
s.sel = Selection::caret(new);
|
||||
s.goal_x = None;
|
||||
sel_result(s)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// In a table: next cell (adds a row at the end). At the start of a list item: demote. With "Set
|
||||
@@ -773,8 +875,12 @@ fn tab(s: &mut Session, _: &Value) -> CmdResult {
|
||||
if s.sel.is_collapsed() && tab_indent_on(s) && tab_indents(s, &f) {
|
||||
return indent_by_tab(s, &f);
|
||||
}
|
||||
if !s.sel.is_collapsed() && s.sel.anchor.path != s.sel.focus.path {
|
||||
// Multiple paragraphs selected: increase indent.
|
||||
let whole_para = !s.sel.is_collapsed() && s.sel.anchor.path == s.sel.focus.path && {
|
||||
let (a, b) = s.sel.ordered();
|
||||
a.off == 0 && s.doc.para_at(&a).is_some_and(|p| b.off == p.len())
|
||||
};
|
||||
if !s.sel.is_collapsed() && (s.sel.anchor.path != s.sel.focus.path || whole_para) {
|
||||
// Several paragraphs or a whole paragraph selected: increase indent.
|
||||
return super::para::indent(s, &Value::Null);
|
||||
}
|
||||
ins_char(s, '\t')
|
||||
@@ -793,7 +899,7 @@ fn tab_indent_on(s: &Session) -> bool {
|
||||
fn tab_indents(s: &mut Session, f: &Pos) -> bool {
|
||||
let Some(para) = s.doc.para_at(f).filter(|p| !p.is_empty()) else { return false };
|
||||
if f.off == 0 {
|
||||
return !s.doc.styles.resolve_para(¶.props).numbering.is_some_and(|n| n.num != 0);
|
||||
return s.doc.styles.resolve_para(¶.props).numbering.is_none_or(|n| n.num == 0);
|
||||
}
|
||||
let l = s.layout();
|
||||
l.line_bounds(f, s.page_hint).is_some_and(|(a, _)| a == *f)
|
||||
@@ -857,11 +963,40 @@ fn backspace(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Deletes the selection or the character before the caret. At a paragraph start it removes list
|
||||
/// numbering while keeping the text position, takes a List Paragraph out of the list, outdents,
|
||||
/// joins the previous paragraph or moves into a preceding table; the command wrapper restores a
|
||||
/// content-control placeholder.
|
||||
/// The first Backspace at a numbered paragraph's start removes the number but keeps the item's
|
||||
/// indent (Word): the text stays where it was, so the indent becomes the paragraph's own.
|
||||
fn remove_number(s: &mut Session, f: &Pos, n: NumRef) -> CmdResult {
|
||||
let text_at = s.doc.numbering.level(n.num, n.level).map(|l| l.indent);
|
||||
let p = s.doc.para_mut(f.story, &f.path)?;
|
||||
if p.props.style.as_deref() == Some("ListParagraph") {
|
||||
if p.props.indent_left.is_none() && p.props.indent_left_chars.is_none_or(|c| c == 0.0) {
|
||||
p.props.indent_left = text_at;
|
||||
}
|
||||
if p.props.indent_first.is_none() && p.props.indent_first_chars.is_none_or(|c| c == 0.0) {
|
||||
p.props.indent_first = Some(0.0);
|
||||
}
|
||||
}
|
||||
p.props.numbering = Some(NumRef { num: 0, level: 0 });
|
||||
p.touch();
|
||||
sel_result(s)
|
||||
}
|
||||
|
||||
fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult {
|
||||
s.goal_x = None;
|
||||
s.pending = None;
|
||||
if !s.sel.is_collapsed() {
|
||||
delete_selection(s)?;
|
||||
let effect = delete_selection_effect(s)?;
|
||||
s.set_ime_edit_outcome(crate::ImeEditOutcome {
|
||||
classified: true,
|
||||
content_changed: effect.content_changed,
|
||||
state_changed: effect.state_changed,
|
||||
transformed: false,
|
||||
selection_changed: false,
|
||||
});
|
||||
return sel_result(s);
|
||||
}
|
||||
let f = s.sel.focus.clone();
|
||||
@@ -872,26 +1007,19 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult {
|
||||
c.note_backspace(&a, &f);
|
||||
}
|
||||
s.sel = Selection { anchor: a, focus: f };
|
||||
delete_selection(s)?;
|
||||
let effect = delete_selection_effect(s)?;
|
||||
s.set_ime_edit_outcome(crate::ImeEditOutcome {
|
||||
classified: true,
|
||||
content_changed: effect.content_changed,
|
||||
state_changed: effect.state_changed,
|
||||
transformed: false,
|
||||
selection_changed: false,
|
||||
});
|
||||
return sel_result(s);
|
||||
}
|
||||
// At the start of a paragraph.
|
||||
if para.props.numbering.is_some_and(|n| n.num != 0) {
|
||||
// First Backspace removes the number but keeps the item's indent (Word): the text stays
|
||||
// where it was, so the indent becomes the paragraph's own (`num: 0` alone drops it).
|
||||
let text_at = para.props.numbering.and_then(|n| s.doc.numbering.level(n.num, n.level)).map(|l| l.indent);
|
||||
let p = s.doc.para_mut(f.story, &f.path)?;
|
||||
if p.props.style.as_deref() == Some("ListParagraph") {
|
||||
if p.props.indent_left.is_none() && p.props.indent_left_chars.is_none_or(|c| c == 0.0) {
|
||||
p.props.indent_left = text_at;
|
||||
}
|
||||
if p.props.indent_first.is_none() && p.props.indent_first_chars.is_none_or(|c| c == 0.0) {
|
||||
p.props.indent_first = Some(0.0);
|
||||
}
|
||||
}
|
||||
p.props.numbering = Some(NumRef { num: 0, level: 0 });
|
||||
p.touch();
|
||||
return sel_result(s);
|
||||
if let Some(n) = para.props.numbering.filter(|n| n.num != 0) {
|
||||
return remove_number(s, &f, n);
|
||||
}
|
||||
if para.props.style.as_deref() == Some("ListParagraph") {
|
||||
// The second one takes it out of the list's indent instead of joining paragraphs.
|
||||
@@ -952,11 +1080,20 @@ fn delete(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Deletes the selection or the character after the caret, joins the next paragraph, handles a
|
||||
/// following table and removes a trailing section break; tracked changes keep their marks.
|
||||
fn delete_inner(s: &mut Session, _: &Value) -> CmdResult {
|
||||
s.goal_x = None;
|
||||
s.pending = None;
|
||||
if !s.sel.is_collapsed() {
|
||||
delete_selection(s)?;
|
||||
let effect = delete_selection_effect(s)?;
|
||||
s.set_ime_edit_outcome(crate::ImeEditOutcome {
|
||||
classified: true,
|
||||
content_changed: effect.content_changed,
|
||||
state_changed: effect.state_changed,
|
||||
transformed: false,
|
||||
selection_changed: false,
|
||||
});
|
||||
return sel_result(s);
|
||||
}
|
||||
let f = s.sel.focus.clone();
|
||||
@@ -964,7 +1101,14 @@ fn delete_inner(s: &mut Session, _: &Value) -> CmdResult {
|
||||
if f.off < para.len() {
|
||||
let b = Pos { off: para.next_boundary(f.off), ..f.clone() };
|
||||
s.sel = Selection { anchor: f, focus: b };
|
||||
delete_selection(s)?;
|
||||
let effect = delete_selection_effect(s)?;
|
||||
s.set_ime_edit_outcome(crate::ImeEditOutcome {
|
||||
classified: true,
|
||||
content_changed: effect.content_changed,
|
||||
state_changed: effect.state_changed,
|
||||
transformed: false,
|
||||
selection_changed: false,
|
||||
});
|
||||
return sel_result(s);
|
||||
}
|
||||
// At the end: join with the next paragraph in the same container.
|
||||
|
||||
@@ -114,6 +114,9 @@ pub struct AutoCorrectPrefs {
|
||||
/// letter or digit makes it Heading 1 (Heading 2 or 3 after one or two leading tabs).
|
||||
/// Off by default.
|
||||
pub heading_styles: bool,
|
||||
/// A second Space after a word, number or closing bracket or quote turns the first into a
|
||||
/// period (macOS's "Add period with double-space"); off by default.
|
||||
pub double_space_period: bool,
|
||||
/// The user's entries: added, or replacing a built-in one.
|
||||
pub entries: Vec<(String, String)>,
|
||||
/// Built-in entries the user deleted.
|
||||
@@ -152,6 +155,7 @@ impl Default for AutoCorrectPrefs {
|
||||
border_lines: true,
|
||||
tab_indent: false,
|
||||
heading_styles: false,
|
||||
double_space_period: false,
|
||||
entries: Vec::new(),
|
||||
removed: Vec::new(),
|
||||
exceptions: Vec::new(),
|
||||
@@ -344,6 +348,7 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
("borderLines", &mut ac.border_lines),
|
||||
("tabIndent", &mut ac.tab_indent),
|
||||
("headingStyles", &mut ac.heading_styles),
|
||||
("doubleSpacePeriod", &mut ac.double_space_period),
|
||||
("autoAddFirstLetter", &mut ac.auto_add_first_letter),
|
||||
("autoAddInitialCaps", &mut ac.auto_add_initial_caps),
|
||||
] {
|
||||
@@ -420,6 +425,7 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
"borderLines": ac.border_lines,
|
||||
"tabIndent": ac.tab_indent,
|
||||
"headingStyles": ac.heading_styles,
|
||||
"doubleSpacePeriod": ac.double_space_period,
|
||||
"autoAddFirstLetter": ac.auto_add_first_letter,
|
||||
"autoAddInitialCaps": ac.auto_add_initial_caps,
|
||||
"entries": ac.list().into_iter().map(|(a, b, builtin)| json!({"from": a, "to": b, "builtIn": builtin})).collect::<Vec<_>>(),
|
||||
@@ -429,6 +435,11 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Builds the tools, review, file and insertion command table: Repeat and macros; AutoCorrect,
|
||||
/// keyboard and ribbon customization and search; Compare, accessibility, inspection, protection,
|
||||
/// versions, recovery, templates and autosave; compatibility, sharing, Quick Parts, fields,
|
||||
/// signatures, objects and spreadsheets; header/footer, table and numbering settings; language,
|
||||
/// markup, editor and selection commands; and design and view options.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
CommandSpec::new("edit.repeat", "Repeat", "Quick Access Toolbar", |s, _| {
|
||||
@@ -457,7 +468,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.pure(),
|
||||
CommandSpec::new("tools.autocorrect", "AutoCorrect Options", "File › Options › Proofing", autocorrect_options)
|
||||
.params(
|
||||
r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"twoInitialCaps"|"capSentences"|"capCells"|"capDays"|"capsLock"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "boldItalic"?: bool (*bold* and _italic_ with real formatting; default false), "tabIndent"?: bool (Set left- and first-indent with tabs and backspaces; default false), "headingStyles"?: bool (Built-in Heading styles; default false), "autoAddFirstLetter"|"autoAddInitialCaps"?: bool (Automatically add words to the exception lists when an automatic capital is undone; default true), "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string, "addInitialCapsException"?: string (keeps TWo INitial CApitals, like "IDs"), "deleteInitialCapsException"?: string} → options, entries and exceptions"#,
|
||||
r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"twoInitialCaps"|"capSentences"|"capCells"|"capDays"|"capsLock"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "boldItalic"?: bool (*bold* and _italic_ with real formatting; default false), "tabIndent"?: bool (Set left- and first-indent with tabs and backspaces; default false), "headingStyles"?: bool (Built-in Heading styles; default false), "doubleSpacePeriod"?: bool (Add a period with double-space; default false), "autoAddFirstLetter"|"autoAddInitialCaps"?: bool (Automatically add words to the exception lists when an automatic capital is undone; default true), "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string, "addInitialCapsException"?: string (keeps TWo INitial CApitals, like "IDs"), "deleteInitialCapsException"?: string} → options, entries and exceptions"#,
|
||||
)
|
||||
.pure(),
|
||||
CommandSpec::new("tools.customizeKeyboard", "Customize Keyboard", "File › Options", customize_keyboard)
|
||||
@@ -521,6 +532,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.doc.settings.protection = protection;
|
||||
s.doc.settings.protect_hash = protect_hash;
|
||||
s.doc.settings.protect_formatting = protect_formatting;
|
||||
// The restored document reuses the open document's id; the body and any equation
|
||||
// editing session are replaced.
|
||||
s.note_body_replaced();
|
||||
s.math = None;
|
||||
s.touch();
|
||||
s.clamp_selection();
|
||||
}
|
||||
@@ -536,8 +551,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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.add_document(doc);
|
||||
s.dirty = true;
|
||||
sel_result(s)
|
||||
})
|
||||
@@ -1332,6 +1346,8 @@ fn compare(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?;
|
||||
s.doc = doc;
|
||||
s.doc.ensure_nonempty();
|
||||
s.note_body_replaced();
|
||||
s.math = None;
|
||||
}
|
||||
let level = p::str(v, "level").unwrap_or("word");
|
||||
let opts = CompareOptions {
|
||||
@@ -1355,7 +1371,8 @@ fn compare(s: &mut Session, v: &Value) -> CmdResult {
|
||||
// Tables match as a whole, by their text.
|
||||
Block::Table(_) => format!("\u{0}table\u{0}{}", opts.key(&block_text(b, 0))),
|
||||
};
|
||||
let ops = lcs_ops(&old.iter().map(key).collect::<Vec<_>>(), &new.iter().map(key).collect::<Vec<_>>());
|
||||
let mut budget = MAX_COMPARE_CELLS;
|
||||
let ops = lcs_ops(&old.iter().map(key).collect::<Vec<_>>(), &new.iter().map(key).collect::<Vec<_>>(), &mut budget);
|
||||
let mut out: Vec<Arc<Block>> = Vec::new();
|
||||
let (mut i, mut j) = (0usize, 0usize);
|
||||
let mut changes = 0usize;
|
||||
@@ -1391,7 +1408,7 @@ fn compare(s: &mut Session, v: &Value) -> CmdResult {
|
||||
for x in 0..dels.max(inss) {
|
||||
let (a, b) = (if x < dels { old.get(i + x) } else { None }, if x < inss { new.get(j + x) } else { None });
|
||||
match (a.map(|x| &**x), b.map(|x| &**x)) {
|
||||
(Some(Block::Para(pa)), Some(Block::Para(pb))) => out.push(para_block(word_diff(pa, pb, ins, del, &opts))),
|
||||
(Some(Block::Para(pa)), Some(Block::Para(pb))) => out.push(para_block(word_diff(pa, pb, ins, del, &opts, &mut budget))),
|
||||
_ => {
|
||||
if let Some(x) = a {
|
||||
out.push(Arc::new(mark_block(x, &|c| c.del = Some(del), 0)));
|
||||
@@ -1412,6 +1429,8 @@ fn compare(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
s.doc.body = out;
|
||||
s.doc.ensure_nonempty();
|
||||
s.note_body_replaced();
|
||||
s.math = None;
|
||||
if show_in != "original" || p::str(v, "original").is_some() {
|
||||
// The result is a new, untitled document: saving it never overwrites either file.
|
||||
s.path = None;
|
||||
@@ -1467,15 +1486,26 @@ enum Op {
|
||||
/// compared as deleted and inserted, never dropped.
|
||||
const MAX_LCS: usize = 4000;
|
||||
|
||||
/// Comparison table cells one compare may fill over all its tables (blocks and every changed
|
||||
/// paragraph pair); once spent, the remaining middles are compared as deleted and inserted.
|
||||
const MAX_COMPARE_CELLS: u64 = 50_000_000;
|
||||
|
||||
/// LCS edit script between two sequences: the common start and end are matched first, then the
|
||||
/// middle (bounded size).
|
||||
fn lcs_ops<T: PartialEq>(a: &[T], b: &[T]) -> Vec<Op> {
|
||||
/// middle (bounded size). The middle's table is charged to `budget`; when it doesn't fit, the
|
||||
/// middle is deleted and inserted whole.
|
||||
fn lcs_ops<T: PartialEq>(a: &[T], b: &[T], budget: &mut u64) -> Vec<Op> {
|
||||
let pre = a.iter().zip(b).take_while(|(x, y)| x == y).count();
|
||||
let (ra, rb) = (a.get(pre..).unwrap_or_default(), b.get(pre..).unwrap_or_default());
|
||||
let suf = ra.iter().rev().zip(rb.iter().rev()).take_while(|(x, y)| x == y).count();
|
||||
let (a, b) = (ra.get(..ra.len() - suf).unwrap_or_default(), rb.get(..rb.len() - suf).unwrap_or_default());
|
||||
let mut ops = vec![Op::Same; pre];
|
||||
let (n, m) = (a.len().min(MAX_LCS), b.len().min(MAX_LCS));
|
||||
let (mut n, mut m) = (a.len().min(MAX_LCS), b.len().min(MAX_LCS));
|
||||
let cells = (n as u64).saturating_mul(m as u64);
|
||||
if cells > *budget {
|
||||
(n, m) = (0, 0);
|
||||
} else {
|
||||
*budget -= cells;
|
||||
}
|
||||
let mut t = vec![vec![0u32; m + 1]; n + 1];
|
||||
for i in (0..n).rev() {
|
||||
for j in (0..m).rev() {
|
||||
@@ -1523,12 +1553,13 @@ const NOISE_MATCH_LEN: usize = 1;
|
||||
/// stripped before running its table, so it's left untouched regardless of length; only a short
|
||||
/// match produced by the table's own backtracking, which can only ever be interior, is a
|
||||
/// candidate.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Kind {
|
||||
Keep,
|
||||
Fold,
|
||||
}
|
||||
|
||||
fn collapse_noise_matches(ops: &[Op]) -> Vec<Op> {
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Kind {
|
||||
Keep,
|
||||
Fold,
|
||||
}
|
||||
let mut runs: Vec<(Op, usize, Kind)> = Vec::new();
|
||||
let mut k = 0;
|
||||
while k < ops.len() {
|
||||
@@ -1555,12 +1586,7 @@ fn collapse_noise_matches(ops: &[Op]) -> Vec<Op> {
|
||||
}
|
||||
let region = &runs[start..i];
|
||||
if region.iter().any(|(_, _, kind)| *kind == Kind::Fold) {
|
||||
// Flatten the region: every original character first (in its own order), then every
|
||||
// revised character (in its own order) — one clean delete, one clean insert.
|
||||
let dels: usize = region.iter().map(|(op, len, kind)| if *kind == Kind::Fold || *op == Op::Del { *len } else { 0 }).sum();
|
||||
let inss: usize = region.iter().map(|(op, len, kind)| if *kind == Kind::Fold || *op == Op::Ins { *len } else { 0 }).sum();
|
||||
out.extend(std::iter::repeat_n(Op::Del, dels));
|
||||
out.extend(std::iter::repeat_n(Op::Ins, inss));
|
||||
flatten_region(&mut out, region);
|
||||
} else {
|
||||
for (op, len, _) in region {
|
||||
out.extend(std::iter::repeat_n(*op, *len));
|
||||
@@ -1570,6 +1596,15 @@ fn collapse_noise_matches(ops: &[Op]) -> Vec<Op> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Flattens a region with a folded run: every original character first (in its own order), then
|
||||
/// every revised character (in its own order) — one clean delete, one clean insert.
|
||||
fn flatten_region(out: &mut Vec<Op>, region: &[(Op, usize, Kind)]) {
|
||||
let dels: usize = region.iter().map(|(op, len, kind)| if *kind == Kind::Fold || *op == Op::Del { *len } else { 0 }).sum();
|
||||
let inss: usize = region.iter().map(|(op, len, kind)| if *kind == Kind::Fold || *op == Op::Ins { *len } else { 0 }).sum();
|
||||
out.extend(std::iter::repeat_n(Op::Del, dels));
|
||||
out.extend(std::iter::repeat_n(Op::Ins, inss));
|
||||
}
|
||||
|
||||
/// Words with the spaces after them ("The ", "cat "), or single characters.
|
||||
fn tokens(s: &str, characters: bool) -> Vec<String> {
|
||||
if characters {
|
||||
@@ -1589,11 +1624,11 @@ fn tokens(s: &str, characters: bool) -> Vec<String> {
|
||||
v
|
||||
}
|
||||
|
||||
fn word_diff(a: &Paragraph, b: &Paragraph, ins: u32, del: u32, opts: &CompareOptions) -> Paragraph {
|
||||
fn word_diff(a: &Paragraph, b: &Paragraph, ins: u32, del: u32, opts: &CompareOptions, budget: &mut u64) -> Paragraph {
|
||||
let (at, bt) = (a.plain_text(), b.plain_text());
|
||||
let chars = opts.characters && at.chars().count().max(bt.chars().count()) <= MAX_CHAR_DIFF;
|
||||
let (ta, tb) = (tokens(&at, chars), tokens(&bt, chars));
|
||||
let ops = lcs_ops(&ta.iter().map(|t| opts.key(t)).collect::<Vec<_>>(), &tb.iter().map(|t| opts.key(t)).collect::<Vec<_>>());
|
||||
let ops = lcs_ops(&ta.iter().map(|t| opts.key(t)).collect::<Vec<_>>(), &tb.iter().map(|t| opts.key(t)).collect::<Vec<_>>(), budget);
|
||||
// Only at character granularity: a matched whole word almost never happens by coincidence,
|
||||
// but a matched single character very often does (digits, punctuation), so only there does
|
||||
// a short interior match need folding back into its surrounding edit.
|
||||
@@ -2242,12 +2277,12 @@ mod tests {
|
||||
fn collapse_noise_matches_merges_short_interior_runs_but_keeps_prefix_suffix() {
|
||||
// "01.09" vs "19.10": a pure LCS finds a scrambled chain of single-character matches
|
||||
// ('1', '.', '0') threaded through what a reader expects to be one clean replacement.
|
||||
let ops = lcs_ops(&['0', '1', '.', '0', '9'], &['1', '9', '.', '1', '0']);
|
||||
let ops = lcs_ops(&['0', '1', '.', '0', '9'], &['1', '9', '.', '1', '0'], &mut { u64::MAX });
|
||||
assert!(ops.contains(&Op::Same), "the raw LCS does find spurious matches here, or this test proves nothing");
|
||||
assert_eq!(collapse_noise_matches(&ops), vec![Op::Del, Op::Del, Op::Del, Op::Del, Op::Del, Op::Ins, Op::Ins, Op::Ins, Op::Ins, Op::Ins]);
|
||||
// A plain single-character replacement flanked by a real common prefix and suffix has no
|
||||
// interior match to begin with (the DP's middle is pure delete/insert), so it's untouched.
|
||||
let ops = lcs_ops(&['a', 'b', 'c', 'x', 'd', 'e', 'f'], &['a', 'b', 'c', 'y', 'd', 'e', 'f']);
|
||||
let ops = lcs_ops(&['a', 'b', 'c', 'x', 'd', 'e', 'f'], &['a', 'b', 'c', 'y', 'd', 'e', 'f'], &mut { u64::MAX });
|
||||
assert_eq!(collapse_noise_matches(&ops), ops);
|
||||
}
|
||||
|
||||
@@ -2265,6 +2300,39 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_bounds_total_diff_work() {
|
||||
// Many long, slightly different paragraphs: each pair alone fits one comparison table,
|
||||
// but the work over all pairs must stay bounded. Pairs past the budget are compared
|
||||
// whole (deleted and inserted), and every pair still counts as a change.
|
||||
let words = 3000;
|
||||
let paras = 12;
|
||||
let doc = |rev: bool| -> String {
|
||||
(0..paras)
|
||||
.map(|p| (0..words).map(|w| if w % 10 == 0 && rev { format!("x{p}w{w}") } else { format!("p{p}w{w}") }).collect::<Vec<_>>().join(" "))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
};
|
||||
let (original, revised) = (doc(false), doc(true));
|
||||
let mut s = Session::new(Document::from_text(&original));
|
||||
let r = s.run("review.compare", &json!({"text": revised})).unwrap();
|
||||
assert_eq!(r["changes"], paras);
|
||||
// A pair diffed word by word keeps most of its text unmarked; one replaced whole keeps
|
||||
// only its common start and end.
|
||||
let word_diffed = |b: &Arc<Block>| {
|
||||
let p = b.as_para().unwrap();
|
||||
let same: usize = p.run_ranges().filter(|(_, c)| c.ins.is_none() && c.del.is_none()).map(|(r, _)| r.len()).sum();
|
||||
same * 2 > p.text.len()
|
||||
};
|
||||
assert!(word_diffed(&s.doc.body[0]));
|
||||
let diffed = s.doc.body.iter().filter(|b| word_diffed(b)).count() as u64;
|
||||
let cells = (words as u64) * (words as u64);
|
||||
assert!(diffed * cells <= 50_000_000 + cells, "{diffed} pairs diffed word by word");
|
||||
assert!(!word_diffed(s.doc.body.last().unwrap()));
|
||||
s.run("review.acceptAll", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), revised);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macros_record_and_run() {
|
||||
let mut s = Session::new(Document::new());
|
||||
|
||||
@@ -109,6 +109,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.params(r#"{"value"?: "light" | "dark" | "system"; without it, toggles light and dark}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("view.stylesPane", "Styles Pane", "Home › Styles", |s, v| toggle(s, v, |x| &mut x.styles_pane)).pure(),
|
||||
// Where the Styles pane sits: docked right or left, or floating (#1756). No `dock` reads it.
|
||||
CommandSpec::new("view.stylesPaneDock", "Styles Pane Position", "Home › Styles", styles_pane_dock)
|
||||
.params(r#"{"dock"?: "left" | "right" | "float"}"#)
|
||||
.pure(),
|
||||
CommandSpec::new("view.commentsPane", "Comments Pane", "Review › Comments", |s, v| toggle(s, v, |x| &mut x.comments_pane)).pure(),
|
||||
CommandSpec::new("view.accessibilityPane", "Accessibility Pane", "Review › Accessibility", |s, v| {
|
||||
toggle(s, v, |x| &mut x.accessibility_pane)
|
||||
@@ -128,6 +132,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
sel_result(s)
|
||||
})
|
||||
.pure(),
|
||||
CommandSpec::new("view.switchWindows", "Switch Windows", "View › Window", switch_windows)
|
||||
.params(r#"{"id"?: n (make this open document the active one)} → {"documents": [{"id", "title", "path", "dirty", "active"}]} in the order they were opened"#)
|
||||
.pure(),
|
||||
CommandSpec::new("view.split", "Split", "View › Window", |s, _| {
|
||||
s.push_ui_request(json!({"toggle": "split"}));
|
||||
sel_result(s)
|
||||
@@ -182,6 +189,16 @@ fn mode(s: &mut Session, m: ViewMode, read: bool) -> CmdResult {
|
||||
Ok(json!({"mode": m, "readMode": read}))
|
||||
}
|
||||
|
||||
fn styles_pane_dock(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if let Some(d) = v.get("dock") {
|
||||
let Some(dock) = d.as_str().and_then(crate::StylesPaneDock::parse) else {
|
||||
return Err(CmdError::Params("dock must be left, right or float".into()));
|
||||
};
|
||||
s.view.styles_pane_dock = dock;
|
||||
}
|
||||
Ok(json!({"dock": s.view.styles_pane_dock.name()}))
|
||||
}
|
||||
|
||||
fn read_column_width(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let width = match v.get("value").and_then(Value::as_str) {
|
||||
Some("auto") => None,
|
||||
@@ -290,3 +307,12 @@ fn zoom(s: &mut Session, v: &Value) -> CmdResult {
|
||||
}
|
||||
Ok(json!({"zoom": s.view.zoom, "fit": s.view.fit}))
|
||||
}
|
||||
|
||||
/// View › Window › Switch Windows: the open documents, or make one of them active.
|
||||
fn switch_windows(s: &mut Session, v: &Value) -> CmdResult {
|
||||
if let Some(x) = v.get("id").filter(|x| !x.is_null()) {
|
||||
let id = x.as_u64().ok_or_else(|| CmdError::Params("`id` must be an open document's id".into()))?;
|
||||
s.activate(id)?;
|
||||
}
|
||||
Ok(json!({"documents": s.documents()}))
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
//! is a link that leads nowhere).
|
||||
//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside
|
||||
//! `/work`), without regard to case on Windows.
|
||||
//! - Writing replaces or removes a directory entry, so for write access the entry itself (its
|
||||
//! folder's links followed, its own name as given) must lie inside the root as well: a link
|
||||
//! outside the root that leads into it is not a way to write outside (#3735).
|
||||
//! - Read and write access are separate: a root given alone grants only its own kind of access.
|
||||
//!
|
||||
//! Roots are in force on a thread inside [`confine`], and in the whole process after
|
||||
@@ -95,7 +98,14 @@ impl AutomationRoots {
|
||||
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path}")),
|
||||
};
|
||||
};
|
||||
if inside(&resolve(path)?, folder) {
|
||||
// Writing replaces or removes the directory entry `path` names (a safe save renames its
|
||||
// temporary file over it, a rename removes it): that entry must be inside the root too,
|
||||
// not only what a link there leads to (#3735).
|
||||
let entry_inside = || match access {
|
||||
Access::Read => Ok(true),
|
||||
Access::Write => resolve_entry(path).map(|entry| inside(&entry, folder)),
|
||||
};
|
||||
if inside(&resolve(path)?, folder) && entry_inside()? {
|
||||
return Ok(());
|
||||
}
|
||||
let root = shown.display();
|
||||
@@ -201,9 +211,27 @@ fn resolve(path: &str) -> Result<PathBuf, String> {
|
||||
return Err(format!("automation path rejected: reserved device names are not allowed: {path}"));
|
||||
}
|
||||
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?;
|
||||
resolve_absolute(path, &absolute)
|
||||
}
|
||||
|
||||
/// The directory entry `path` names: its folder resolved as [`resolve`] resolves it, then its own
|
||||
/// name as given, so a link there is the entry itself, not what it leads to. That entry is what
|
||||
/// replacing or removing the file changes.
|
||||
fn resolve_entry(path: &str) -> Result<PathBuf, String> {
|
||||
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?;
|
||||
let (Some(folder), Some(name)) = (absolute.parent(), absolute.file_name()) else {
|
||||
return Err(format!("automation path rejected: not a file name: {path}"));
|
||||
};
|
||||
let mut entry = resolve_absolute(path, folder)?;
|
||||
entry.push(name);
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
/// [`resolve`] for `absolute`, `path` made absolute (`path` as given, for messages).
|
||||
fn resolve_absolute(path: &str, absolute: &Path) -> Result<PathBuf, String> {
|
||||
// The names below the nearest existing folder, last first.
|
||||
let mut missing: Vec<OsString> = vec![];
|
||||
let mut at = absolute.as_path();
|
||||
let mut at = absolute;
|
||||
loop {
|
||||
match std::fs::canonicalize(at) {
|
||||
Ok(mut found) => {
|
||||
@@ -382,6 +410,17 @@ mod tests {
|
||||
// A link to a folder inside the root stays inside.
|
||||
assert!(made(&base.join("in/sub"), &base.join("in/alias")));
|
||||
assert_eq!(roots.check(&s(base.join("in/alias/x.docx")), Access::Read), Ok(()));
|
||||
// A link outside the write root that leads into it: writing would replace the link,
|
||||
// outside (#3735). One inside that leads to a file inside is fine.
|
||||
std::fs::write(base.join("out/doc.docx"), "x").unwrap();
|
||||
assert!(made(&base.join("out/doc.docx"), &base.join("outside/alias.docx")));
|
||||
let e = roots.check(&s(base.join("outside/alias.docx")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("outside the write root"), "the link itself is outside: {e}");
|
||||
assert!(made(&base.join("out/doc.docx"), &base.join("out/alias.docx")));
|
||||
assert_eq!(roots.check(&s(base.join("out/alias.docx")), Access::Write), Ok(()));
|
||||
// A folder link outside that leads into the root: the entry is inside, as the system opens it.
|
||||
assert!(made(&base.join("out"), &base.join("outside/to-out")));
|
||||
assert_eq!(roots.check(&s(base.join("outside/to-out/new.docx")), Access::Write), Ok(()));
|
||||
// A root given through a link is the folder it leads to.
|
||||
assert!(made(&base.join("in"), &base.join("in-link")));
|
||||
let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap();
|
||||
|
||||
+89
-21
@@ -113,6 +113,20 @@ pub fn is_empty_file(_path: &std::path::Path) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// The canonical form of `path` (symbolic links and `.`/`..` resolved), for telling whether two
|
||||
/// spellings name one file; `None` when it can't be resolved or is outside the readable roots.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn canonical(path: &std::path::Path) -> Option<std::path::PathBuf> {
|
||||
crate::file_access::check_read(&path.to_string_lossy()).ok()?;
|
||||
std::fs::canonicalize(path).ok()
|
||||
}
|
||||
|
||||
/// [`canonical`]: on the web files come through the browser, never by path.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn canonical(_path: &std::path::Path) -> Option<std::path::PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Can files with this extension be saved with a password (Word's packages)?
|
||||
pub fn can_encrypt(name: &str) -> bool {
|
||||
is_word_package(&ext_of(name))
|
||||
@@ -146,7 +160,8 @@ fn open_binary_doc(bytes: &[u8], password: Option<&str>) -> Result<(Document, bo
|
||||
}
|
||||
|
||||
/// [`open_bytes`] for a file that may be password-protected: a Word document encrypted with a
|
||||
/// password ([MS-OFFCRYPTO] agile encryption, or the Word 97-2003 schemes) is decrypted with
|
||||
/// password ([MS-OFFCRYPTO] agile or Standard encryption, under any Word extension, or the
|
||||
/// Word 97-2003 schemes) is decrypted with
|
||||
/// `password` first. Also says
|
||||
/// whether the file was encrypted. Without the password needed the error satisfies
|
||||
/// [`needs_password`]; with a wrong one, [`wrong_password`].
|
||||
@@ -506,7 +521,7 @@ impl LocalImages {
|
||||
left.map(|_| ())
|
||||
}
|
||||
|
||||
/// The canonical path `src` names, if it's inside the folder.
|
||||
/// The canonical path `src` names, if it's inside the folder and may be read.
|
||||
fn resolve(&self, src: &str) -> Option<std::path::PathBuf> {
|
||||
let src = src.split(['#', '?']).next().unwrap_or("");
|
||||
if src.is_empty() || src.starts_with("//") || src.split_once(':').is_some_and(|(scheme, _)| !scheme.contains('/')) {
|
||||
@@ -520,6 +535,9 @@ impl LocalImages {
|
||||
let dir = self.dir.as_ref()?;
|
||||
// Canonical paths have `..` and symlinks resolved, so this keeps both inside the folder.
|
||||
let path = std::fs::canonicalize(dir.join(rel)).ok()?;
|
||||
// The folder is the one the HTML path names, which may be a link from outside the
|
||||
// automation read root; the picture itself must be inside it (#3736).
|
||||
crate::file_access::check_read(&path.to_string_lossy()).ok()?;
|
||||
path.starts_with(dir).then_some(path)
|
||||
}
|
||||
}
|
||||
@@ -527,21 +545,67 @@ impl LocalImages {
|
||||
/// Up to `limit + 1` bytes of the regular file at `path` (more than `limit` means too large), or
|
||||
/// `None` for anything else. A named pipe blocks when opened and a device reports a length of 0
|
||||
/// and may never end, so the path is checked before opening, the opened handle is checked again,
|
||||
/// and the read is bounded whatever length the file reports.
|
||||
/// and the read is bounded whatever length the file reports. Public for the app (a copied picture
|
||||
/// file, Paste Special).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_regular_file(path: &std::path::Path, limit: u64) -> Option<Vec<u8>> {
|
||||
use std::io::Read;
|
||||
if !std::fs::metadata(path).ok()?.is_file() {
|
||||
pub fn read_regular_file(path: &std::path::Path, limit: u64) -> Option<Vec<u8>> {
|
||||
let (file, meta) = open_regular_file(path).ok()?;
|
||||
read_bounded(file, &meta, limit).ok()
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn read_regular_file(_path: &std::path::Path, _limit: u64) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The bytes of the regular file at `path` when it holds at most `limit` of them; `None` for
|
||||
/// anything else. A file whose opened handle reports more than `limit` bytes is refused before
|
||||
/// anything is read; the read stays bounded for a file that grows after the check. Public for the
|
||||
/// app (a copied picture file, Paste Special).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn read_regular_file_within(path: &std::path::Path, limit: u64) -> Option<Vec<u8>> {
|
||||
let (file, meta) = open_regular_file(path).ok()?;
|
||||
if meta.len() > limit {
|
||||
return None;
|
||||
}
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
let meta = file.metadata().ok()?;
|
||||
read_bounded(file, &meta, limit).ok().filter(|b| b.len() as u64 <= limit)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn read_regular_file_within(_path: &std::path::Path, _limit: u64) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The regular file at `path`, opened once, with the opened handle's metadata. The path is
|
||||
/// checked before opening (a named pipe blocks in `open` until a writer appears) and the handle
|
||||
/// after (the path may have been swapped in between); anything but a regular file is "not a file".
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn open_regular_file(path: &std::path::Path) -> Result<(std::fs::File, std::fs::Metadata), String> {
|
||||
let meta = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
if !meta.is_file() {
|
||||
return None;
|
||||
return Err(format!("{}: not a file", path.display()));
|
||||
}
|
||||
let mut open = std::fs::OpenOptions::new();
|
||||
open.read(true);
|
||||
// A path swapped for a named pipe since the check above would block `open`; non-blocking
|
||||
// opens it at once (no effect on a regular file) and the handle check below refuses it.
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::OpenOptionsExt::custom_flags(&mut open, libc::O_NONBLOCK);
|
||||
let file = open.open(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let meta = file.metadata().map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
if !meta.is_file() {
|
||||
return Err(format!("{}: not a file", path.display()));
|
||||
}
|
||||
Ok((file, meta))
|
||||
}
|
||||
|
||||
/// Up to `limit + 1` bytes of `file` (more than `limit` means too large), however long it says it is.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_bounded(file: std::fs::File, meta: &std::fs::Metadata, limit: u64) -> std::io::Result<Vec<u8>> {
|
||||
use std::io::Read;
|
||||
let mut out = Vec::with_capacity(usize::try_from(meta.len().min(limit)).unwrap_or(0));
|
||||
file.take(limit.saturating_add(1)).read_to_end(&mut out).ok()?;
|
||||
Some(out)
|
||||
file.take(limit.saturating_add(1)).read_to_end(&mut out)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Largest file [`read_file`] reads whole — the same 2 GB ceiling [`open_path_with`] gives a
|
||||
@@ -563,29 +627,33 @@ pub fn read_file(path: &str) -> Result<Vec<u8>, String> {
|
||||
}
|
||||
|
||||
/// [`read_file`] for a file that must be a regular file of at most `max` bytes, checked before
|
||||
/// anything is read (a font named by the user, which could be any size).
|
||||
/// anything is read (a font named by the user, which could be any size). The file is opened once
|
||||
/// and the read stops past `max`, so a file swapped or grown after the check is refused too (#3666).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn read_file_capped(path: &str, max: u64, what: &str) -> Result<Vec<u8>, String> {
|
||||
crate::file_access::check_read(path)?;
|
||||
let meta = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
if !meta.is_file() {
|
||||
return Err(format!("{path}: not a file"));
|
||||
}
|
||||
let too_large = || format!("{path}: the {what} is larger than {} MB", max >> 20);
|
||||
let (file, meta) = open_regular_file(std::path::Path::new(path))?;
|
||||
if meta.len() > max {
|
||||
return Err(format!("{path}: the {what} is larger than {} MB", max >> 20));
|
||||
return Err(too_large());
|
||||
}
|
||||
std::fs::read(path).map_err(|e| format!("{path}: {e}"))
|
||||
let bytes = read_bounded(file, &meta, max).map_err(|e| format!("{path}: {e}"))?;
|
||||
if bytes.len() as u64 > max {
|
||||
return Err(too_large());
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// The first `max` bytes of the file at `path` (fewer when it is shorter), inside the automation
|
||||
/// read root when there is one: enough to tell a file's format by its start.
|
||||
/// read root when there is one: enough to tell a file's format by its start. Only a regular file
|
||||
/// is opened: a named pipe would block the open until a writer appeared (#3666).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn read_file_head(path: &str, max: u64) -> Result<Vec<u8>, String> {
|
||||
use std::io::Read;
|
||||
crate::file_access::check_read(path)?;
|
||||
let f = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let (file, _) = open_regular_file(std::path::Path::new(path))?;
|
||||
let mut head = Vec::new();
|
||||
f.take(max).read_to_end(&mut head).map_err(|e| format!("{path}: {e}"))?;
|
||||
file.take(max).read_to_end(&mut head).map_err(|e| format!("{path}: {e}"))?;
|
||||
Ok(head)
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,38 @@ pub use io::Password;
|
||||
pub use keymap::KeyMap;
|
||||
pub use ribbon::RibbonLayout;
|
||||
pub use session::{
|
||||
CellRange, ColumnBlock, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, UNDO_LIST_MAX, ViewState, blank_document, set_system_locale,
|
||||
CellRange, ColumnBlock, DocInfo, DocState, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, StylesPaneDock, UNDO_LIST_MAX,
|
||||
ViewState, WindowView, blank_document, next_math_session, set_system_locale,
|
||||
};
|
||||
/// The outcome a command reports for IME receipt classification.
|
||||
///
|
||||
/// A command that can be called by an input method must classify its committed effects. Only a
|
||||
/// classified content change without an automatic rewrite may mint an IME receipt; any other
|
||||
/// committed state change still advances the IME state revision and therefore makes a mixed
|
||||
/// frame `Other` rather than certifying it.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ImeEditOutcome {
|
||||
/// The handler explicitly classified the committed effects.
|
||||
pub classified: bool,
|
||||
/// IME-visible content changed. Only this gates an IME receipt.
|
||||
pub content_changed: bool,
|
||||
/// Any committed document state changed, including style or representation changes.
|
||||
pub state_changed: bool,
|
||||
/// An automatic engine rewrite was applied (AutoCorrect, build-up, AutoFormat).
|
||||
pub transformed: bool,
|
||||
/// The caret or selection changed. Filled in by [`Session::run`].
|
||||
pub selection_changed: bool,
|
||||
}
|
||||
|
||||
/// The largest text one `equation.type` call inserts, in characters. The engine truncates to this
|
||||
/// in both linear and built-up modes; the UI refuses an IME commit above it instead of inserting a
|
||||
/// prefix.
|
||||
pub const MAX_EQUATION_TYPE_CHARS: usize = 10_000;
|
||||
|
||||
/// The largest number of unit steps one `equation.deleteSurrounding` request may take on either
|
||||
/// side of the caret. A hostile IME count must not become an unbounded command.
|
||||
pub const MAX_EQUATION_DELETE_UNITS: usize = 64;
|
||||
|
||||
pub use wordcraft_doc as doc;
|
||||
pub use wordcraft_geom::Region;
|
||||
pub use wordcraft_layout as layout;
|
||||
@@ -170,8 +200,12 @@ mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_bidi;
|
||||
#[cfg(test)]
|
||||
mod tests_documents;
|
||||
#[cfg(test)]
|
||||
mod tests_fonts;
|
||||
#[cfg(test)]
|
||||
mod tests_ime_noop;
|
||||
#[cfg(test)]
|
||||
mod tests_revision_checks;
|
||||
#[cfg(test)]
|
||||
mod tests_typing;
|
||||
|
||||
+118
-39
@@ -14,7 +14,9 @@ pub struct RecoveryInfo {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod desktop;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) use desktop::Recovery;
|
||||
use desktop::{Applied, Request};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) use desktop::{DocRecovery, Recovery, Slot};
|
||||
|
||||
impl Session {
|
||||
/// The desktop host supplies the profile folder and a thread-safe wakeup.
|
||||
@@ -24,10 +26,13 @@ impl Session {
|
||||
self.recovery = Some(Recovery::start(root, wake)?);
|
||||
Ok(())
|
||||
}
|
||||
/// Copies left by an earlier run (or another window's documents that have closed), newest
|
||||
/// first. The copies this session's open documents keep are not listed: they are not lost.
|
||||
pub fn recovery_documents(&self) -> Vec<RecoveryInfo> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(r) = &self.recovery {
|
||||
return r.documents.clone();
|
||||
let held = |id: &str| self.recovery_slots().any(|(_, _, s)| s.id.as_deref() == Some(id)) || r.closing().any(|c| c == id);
|
||||
return r.documents.iter().filter(|d| !held(&d.id)).cloned().collect();
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
@@ -52,23 +57,19 @@ impl Session {
|
||||
}
|
||||
None
|
||||
}
|
||||
/// A delayed restore can't replace edits made while it was loading. Restores have no path.
|
||||
/// Called every frame: takes in the worker's answer, opens a restored copy, and queues the
|
||||
/// next job. Every open document's copy is kept, the parked ones' too, without making them
|
||||
/// active: a frame compares each document's revision and timer, and clones a document only
|
||||
/// when its copy is due.
|
||||
pub fn recovery_tick(&mut self) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let Some(mut r) = self.recovery.take() else { return };
|
||||
if let Some((snapshot, expected)) = r.poll() {
|
||||
if expected == (self.document_id(), self.rev()) && self.password.is_none() {
|
||||
let id = snapshot.info.id;
|
||||
self.set_document(snapshot.document);
|
||||
self.path = None;
|
||||
self.dirty = true;
|
||||
r.adopt(id, self.document_id(), self.rev());
|
||||
} else {
|
||||
r.error = Some("Recovery cancelled: the document changed while the copy was loading.".into());
|
||||
}
|
||||
if let Some(e) = r.receive(false) {
|
||||
self.apply_recovery(&mut r, e);
|
||||
}
|
||||
if let Err(e) = r.tick(self, std::time::Instant::now()) {
|
||||
self.open_held_restore(&mut r);
|
||||
if let Err(e) = r.tick(self.recovery_docs(), std::time::Instant::now()) {
|
||||
r.error = Some(e);
|
||||
}
|
||||
if let Some(e) = &r.error {
|
||||
@@ -77,46 +78,119 @@ impl Session {
|
||||
self.recovery = Some(r);
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn apply_recovery(&mut self, r: &mut Recovery, e: desktop::Event) {
|
||||
match r.apply(e) {
|
||||
// Opened by the next tick, never in the middle of a command.
|
||||
Applied::Loaded(snapshot, request) => r.held = Some((snapshot, request)),
|
||||
applied => {
|
||||
for d in self.recovery_docs() {
|
||||
if let Applied::Failed { document, .. } = &applied
|
||||
&& *document != d.id
|
||||
{
|
||||
continue;
|
||||
}
|
||||
d.slot.apply(&applied);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A restored copy opens as another document, or replaces the untouched blank document that
|
||||
/// asked for it once that document is active, so no work is ever replaced. The restored
|
||||
/// document keeps its copy in the record it came from.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn open_held_restore(&mut self, r: &mut Recovery) {
|
||||
let Some((_, request)) = &r.held else { return };
|
||||
let request = *request;
|
||||
if request.replace && self.is_open(request.document) && request.document != self.document_id() {
|
||||
return;
|
||||
}
|
||||
let Some((snapshot, _)) = r.held.take() else { return };
|
||||
let id = snapshot.info.id.clone();
|
||||
if !request.replace {
|
||||
self.add_document(snapshot.document);
|
||||
} else if request.document == self.document_id() && request.revision == self.rev() && self.password.is_none() {
|
||||
self.set_document(snapshot.document);
|
||||
self.path = None;
|
||||
} else {
|
||||
r.error = Some("Recovery cancelled: the document changed while the copy was loading.".into());
|
||||
return;
|
||||
}
|
||||
self.dirty = true;
|
||||
self.recovery_slot.adopt(id, self.rev());
|
||||
}
|
||||
pub fn recovery_wait(&self) -> Option<std::time::Duration> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(r) = &self.recovery
|
||||
&& self.dirty
|
||||
&& self.password.is_none()
|
||||
&& !r.busy
|
||||
{
|
||||
return r.wait(self.document_id(), self.rev());
|
||||
if let Some(r) = &self.recovery {
|
||||
return r.wait(self.recovery_slots());
|
||||
}
|
||||
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.
|
||||
/// Waits for recovery to finish anything already under way, then deletes the copies no
|
||||
/// open document needs: closed documents', and those of documents saved or protected since
|
||||
/// their last copy. Dirty documents keep theirs. 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) {
|
||||
if let Some(e) = r.receive(true) {
|
||||
self.apply_recovery(&mut r, e);
|
||||
}
|
||||
let mut discards: Vec<(String, Option<u64>)> = r.take_closed().into_iter().map(|id| (id, None)).collect();
|
||||
discards.extend(self.recovery_docs().filter(|d| !d.wants_copy).filter_map(|d| d.slot.id.clone().map(|id| (id, Some(d.id)))));
|
||||
for (id, document) in discards {
|
||||
match r.discard_now(&id, document) {
|
||||
Ok(e) => self.apply_recovery(&mut r, e),
|
||||
Err(e) => r.error = Some(e),
|
||||
}
|
||||
}
|
||||
if let Some(e) = &r.error {
|
||||
log::warn!("recovery: {e}");
|
||||
}
|
||||
self.recovery = Some(r);
|
||||
}
|
||||
}
|
||||
/// The user discarded the active document's unsaved changes without closing it (Don't Save
|
||||
/// when quitting): its recovery copy goes too, and another is made only after a further edit.
|
||||
pub fn discard_recovery_copy(&mut self) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(r) = &mut self.recovery
|
||||
&& let Some(id) = self.recovery_slot.id.take()
|
||||
{
|
||||
r.close(id);
|
||||
self.recovery_slot.revision = Some(self.rev());
|
||||
}
|
||||
}
|
||||
/// 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> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let document = self.document_id();
|
||||
if let Some(r) = &mut self.recovery
|
||||
&& r.document_is(document)
|
||||
{
|
||||
r.purge().map_err(CmdError::Failed)?;
|
||||
}
|
||||
if let Some(mut r) = self.recovery.take() {
|
||||
let result = self.purge_recovery(&mut r);
|
||||
self.recovery = Some(r);
|
||||
result.map_err(CmdError::Failed)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Waits out the job under way (it may be this document's copy being written), then deletes
|
||||
/// the active document's copy.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn purge_recovery(&mut self, r: &mut Recovery) -> Result<(), String> {
|
||||
if let Some(e) = r.receive(true) {
|
||||
self.apply_recovery(r, e);
|
||||
}
|
||||
let Some(id) = self.recovery_slot.id.clone() else { return Ok(()) };
|
||||
let e = r.discard_now(&id, Some(self.document_id()))?;
|
||||
self.apply_recovery(r, e);
|
||||
if self.recovery_slot.id.is_some() {
|
||||
return Err(r.error.clone().unwrap_or_else(|| "the recovery copy could not be removed".into()));
|
||||
}
|
||||
self.recovery_slot.revision = None;
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn recover(&mut self, v: &Value) -> CmdResult {
|
||||
let restore = v.get("restore");
|
||||
let discard = v.get("discard");
|
||||
@@ -126,14 +200,19 @@ impl Session {
|
||||
if restore.is_some() || discard.is_some() || v.get("refresh").is_some() {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let expected = (self.document_id(), self.rev());
|
||||
let request =
|
||||
Request { document: self.document_id(), revision: self.rev(), replace: self.is_untouched_blank() && self.password.is_none() };
|
||||
// Only listed copies: never one an open document keeps.
|
||||
let listed = self.recovery_documents();
|
||||
let known = |id: Option<&str>, what: &str| -> Result<String, CmdError> {
|
||||
let id = id.ok_or_else(|| CmdError::Params(format!("{what} must be a recovery id")))?;
|
||||
if listed.iter().any(|d| d.id == id) { Ok(id.to_string()) } else { Err(CmdError::Failed("no such recovery copy".into())) }
|
||||
};
|
||||
let r = self.recovery.as_mut().ok_or_else(|| CmdError::Disabled("local recovery requires the desktop host".into()))?;
|
||||
if let Some(id) = restore {
|
||||
r.restore(id.as_str().ok_or_else(|| CmdError::Params("restore must be a recovery id".into()))?, expected)
|
||||
.map_err(CmdError::Failed)?;
|
||||
r.restore(&known(id.as_str(), "restore")?, request).map_err(CmdError::Failed)?;
|
||||
} else if let Some(id) = discard {
|
||||
r.discard(id.as_str().ok_or_else(|| CmdError::Params("discard must be a recovery id".into()))?, expected)
|
||||
.map_err(CmdError::Failed)?;
|
||||
r.discard(&known(id.as_str(), "discard")?).map_err(CmdError::Failed)?;
|
||||
} else if v.get("refresh").and_then(Value::as_bool) == Some(true) {
|
||||
r.refresh().map_err(CmdError::Failed)?;
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use super::RecoveryInfo;
|
||||
use crate::Session;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
@@ -373,34 +372,123 @@ impl<W: Write> Write for Limited<W> {
|
||||
}
|
||||
}
|
||||
enum Job {
|
||||
Save(Box<Snapshot>),
|
||||
/// Write `document`'s copy.
|
||||
Save(Box<Snapshot>, u64),
|
||||
Scan,
|
||||
Load(String, (u64, u64)),
|
||||
Discard(String),
|
||||
Load(String, Request),
|
||||
/// Delete a record: an open document's copy it no longer needs, a closed document's, or one
|
||||
/// from an earlier run that the user discarded (`None`).
|
||||
Discard(String, Option<u64>),
|
||||
}
|
||||
enum Outcome {
|
||||
Done,
|
||||
Discarded(String),
|
||||
Loaded(Box<Snapshot>, (u64, u64)),
|
||||
Loaded(Box<Snapshot>, Request),
|
||||
}
|
||||
struct Event {
|
||||
pub(crate) struct Event {
|
||||
documents: Vec<RecoveryInfo>,
|
||||
outcome: Result<Outcome, String>,
|
||||
/// The failure would repeat for the same document; see [`SaveError`].
|
||||
permanent: bool,
|
||||
/// The open document a failed save or discard was for.
|
||||
document: Option<u64>,
|
||||
/// The record a failed discard was for.
|
||||
discard: Option<String>,
|
||||
}
|
||||
/// Who asked for a restore, and whether the copy replaces that document (an untouched blank
|
||||
/// one) or opens as another document.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct Request {
|
||||
pub document: u64,
|
||||
pub revision: u64,
|
||||
pub replace: bool,
|
||||
}
|
||||
/// One open document's recovery state: the record its copy is kept in, the revision that copy
|
||||
/// holds, and when the next copy may be written. Each open document has its own, parked with it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct Slot {
|
||||
pub id: Option<String>,
|
||||
pub revision: Option<u64>,
|
||||
pub due: Instant,
|
||||
/// A failed discard of this document's copy is tried again from then on.
|
||||
pub retry: Option<Instant>,
|
||||
}
|
||||
impl Default for Slot {
|
||||
fn default() -> Self {
|
||||
Self { id: None, revision: None, due: Instant::now() + INTERVAL, retry: None }
|
||||
}
|
||||
}
|
||||
impl Slot {
|
||||
/// The document now keeps its copy in record `id`, which holds `revision`.
|
||||
pub fn adopt(&mut self, id: String, revision: u64) {
|
||||
self.id = Some(id);
|
||||
self.revision = Some(revision);
|
||||
self.due = Instant::now() + INTERVAL;
|
||||
self.retry = None;
|
||||
}
|
||||
}
|
||||
/// A worker report, for the session to apply to its documents.
|
||||
pub(crate) enum Applied {
|
||||
Nothing,
|
||||
/// Record `id` is gone: no document keeps its copy there any more.
|
||||
Discarded(String),
|
||||
/// A restored copy, to open.
|
||||
Loaded(Box<Snapshot>, Request),
|
||||
/// Writing or discarding `document`'s copy failed.
|
||||
Failed {
|
||||
document: u64,
|
||||
discard: bool,
|
||||
permanent: bool,
|
||||
},
|
||||
}
|
||||
impl Slot {
|
||||
/// Take in a report about this document's copy.
|
||||
pub fn apply(&mut self, applied: &Applied) {
|
||||
match applied {
|
||||
Applied::Discarded(id) if self.id.as_deref() == Some(id) => {
|
||||
self.id = None;
|
||||
self.retry = None;
|
||||
}
|
||||
Applied::Failed { discard, permanent, .. } => {
|
||||
let now = Instant::now();
|
||||
if *discard {
|
||||
self.retry = Some(now + INTERVAL);
|
||||
} else if !permanent {
|
||||
// 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.
|
||||
self.revision = None;
|
||||
}
|
||||
self.due = now + INTERVAL;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// What [`Recovery::tick`] reads from an open document, and the document's [`Slot`].
|
||||
pub(crate) struct DocRecovery<'a> {
|
||||
pub id: u64,
|
||||
/// Dirty and without a password: the document should have a current copy.
|
||||
pub wants_copy: bool,
|
||||
pub rev: u64,
|
||||
pub doc: &'a Document,
|
||||
pub path: Option<&'a Path>,
|
||||
pub slot: &'a mut Slot,
|
||||
}
|
||||
/// The process's recovery worker: one thread and one store for every open document, so records
|
||||
/// are locked once per process and the list of copies is the same whichever document is shown.
|
||||
pub(crate) struct Recovery {
|
||||
tx: mpsc::SyncSender<Job>,
|
||||
rx: mpsc::Receiver<Event>,
|
||||
/// Every record the store found or wrote, this process's own included.
|
||||
pub documents: Vec<RecoveryInfo>,
|
||||
pub ready: bool,
|
||||
pub busy: bool,
|
||||
pub error: Option<String>,
|
||||
document: Option<u64>,
|
||||
id: Option<String>,
|
||||
revision: Option<u64>,
|
||||
due: Instant,
|
||||
alive: bool,
|
||||
/// Records of closed documents waiting to be deleted, and whether that is queued.
|
||||
closed: Vec<(String, bool)>,
|
||||
/// A loaded copy that replaces its (untouched blank) document once that document is active.
|
||||
pub held: Option<(Box<Snapshot>, Request)>,
|
||||
}
|
||||
impl Recovery {
|
||||
pub fn start(root: PathBuf, wake: Arc<dyn Fn() + Send + Sync>) -> Result<Self, String> {
|
||||
@@ -412,47 +500,47 @@ impl Recovery {
|
||||
let mut store = match Store::new(root) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
let _ = events.send(Event { documents: Vec::new(), outcome: Err(e), permanent: false });
|
||||
let _ = events.send(Event { documents: Vec::new(), outcome: Err(e), permanent: false, document: None, discard: None });
|
||||
wake();
|
||||
return;
|
||||
}
|
||||
};
|
||||
let initial = store.scan().map(|_| Outcome::Done);
|
||||
if events.send(Event { documents: store.inventory(), outcome: initial, permanent: false }).is_err() {
|
||||
if events.send(Event { documents: store.inventory(), outcome: initial, permanent: false, document: None, discard: None }).is_err() {
|
||||
return;
|
||||
}
|
||||
wake();
|
||||
while let Ok(job) = jobs.recv() {
|
||||
let mut permanent = false;
|
||||
let (mut document, mut discard) = (None, None);
|
||||
let result = match job {
|
||||
Job::Save(s) => store.save(*s).map(|_| Outcome::Done).map_err(|e| {
|
||||
permanent = e.permanent;
|
||||
e.message
|
||||
}),
|
||||
Job::Save(s, d) => {
|
||||
document = Some(d);
|
||||
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)),
|
||||
Job::Load(id, request) => store.load(&id).map(|s| Outcome::Loaded(Box::new(s), request)),
|
||||
Job::Discard(id, d) => {
|
||||
document = d;
|
||||
let r = store.discard(&id).map(|_| Outcome::Discarded(id.clone()));
|
||||
discard = Some(id);
|
||||
r
|
||||
}
|
||||
};
|
||||
if events.send(Event { documents: store.inventory(), outcome: result, permanent }).is_err() {
|
||||
if result.is_ok() {
|
||||
(document, discard) = (None, None);
|
||||
}
|
||||
if events.send(Event { documents: store.inventory(), outcome: result, permanent, document, discard }).is_err() {
|
||||
break;
|
||||
}
|
||||
wake();
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Self {
|
||||
tx,
|
||||
rx,
|
||||
documents: Vec::new(),
|
||||
ready: false,
|
||||
busy: true,
|
||||
error: None,
|
||||
document: None,
|
||||
id: None,
|
||||
revision: None,
|
||||
due: Instant::now() + INTERVAL,
|
||||
alive: true,
|
||||
})
|
||||
Ok(Self { tx, rx, documents: Vec::new(), ready: false, busy: true, error: None, alive: true, closed: Vec::new(), held: None })
|
||||
}
|
||||
fn queue(&mut self, job: Job) -> Result<(), String> {
|
||||
if !self.alive {
|
||||
@@ -466,33 +554,23 @@ impl Recovery {
|
||||
self.error = None;
|
||||
Ok(())
|
||||
}
|
||||
pub fn poll(&mut self) -> Option<(Snapshot, (u64, u64))> {
|
||||
match self.rx.try_recv() {
|
||||
/// The worker's answer to the job under way: waits for it when `block`, else only takes one
|
||||
/// that has arrived.
|
||||
pub fn receive(&mut self, block: bool) -> Option<Event> {
|
||||
let e = if block {
|
||||
if !self.busy {
|
||||
return None;
|
||||
}
|
||||
self.rx.recv().map_err(|_| mpsc::TryRecvError::Disconnected)
|
||||
} else {
|
||||
self.rx.try_recv()
|
||||
};
|
||||
match e {
|
||||
Ok(e) => {
|
||||
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)) => {
|
||||
if self.id.as_deref() == Some(&id) {
|
||||
self.id = None;
|
||||
}
|
||||
None
|
||||
}
|
||||
Ok(Outcome::Loaded(s, expected)) => Some((*s, expected)),
|
||||
Err(e) => {
|
||||
self.error = Some(e);
|
||||
// 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
|
||||
}
|
||||
}
|
||||
self.documents.clone_from(&e.documents);
|
||||
Some(e)
|
||||
}
|
||||
Err(mpsc::TryRecvError::Empty) => None,
|
||||
Err(mpsc::TryRecvError::Disconnected) => {
|
||||
@@ -505,140 +583,144 @@ impl Recovery {
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn adopt(&mut self, id: String, document: u64, revision: u64) {
|
||||
self.id = Some(id);
|
||||
self.document = Some(document);
|
||||
self.revision = Some(revision);
|
||||
self.due = Instant::now() + INTERVAL;
|
||||
/// Run `job` now and wait for its answer.
|
||||
fn run_now(&mut self, job: Job) -> Result<Event, String> {
|
||||
self.queue(job)?;
|
||||
self.receive(true).ok_or_else(|| "recovery worker is unavailable".to_string())
|
||||
}
|
||||
pub fn tick(&mut self, s: &Session, now: Instant) -> Result<(), String> {
|
||||
if self.document != Some(s.document_id()) {
|
||||
self.document = Some(s.document_id());
|
||||
self.id = None;
|
||||
self.revision = None;
|
||||
/// Discard record `id` now (the worker must be idle) and wait for it.
|
||||
pub fn discard_now(&mut self, id: &str, document: Option<u64>) -> Result<Event, String> {
|
||||
self.run_now(Job::Discard(id.into(), document))
|
||||
}
|
||||
/// A closed document's record: deleted at the next chance, and not listed meanwhile.
|
||||
pub fn close(&mut self, id: String) {
|
||||
if !self.closed.iter().any(|(c, _)| *c == id) {
|
||||
self.closed.push((id, false));
|
||||
}
|
||||
}
|
||||
/// Records the open documents of this process don't hold any more but haven't deleted yet.
|
||||
pub fn closing(&self) -> impl Iterator<Item = &str> {
|
||||
self.closed.iter().map(|(id, _)| id.as_str())
|
||||
}
|
||||
/// Closed documents' records not yet queued for deletion.
|
||||
pub fn take_closed(&mut self) -> Vec<String> {
|
||||
self.closed
|
||||
.iter_mut()
|
||||
.filter(|(_, queued)| !*queued)
|
||||
.map(|(id, queued)| {
|
||||
*queued = true;
|
||||
id.clone()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// Take in what the worker reported: the record lists here, and what the session applies
|
||||
/// to its documents.
|
||||
pub fn apply(&mut self, e: Event) -> Applied {
|
||||
match e.outcome {
|
||||
Ok(Outcome::Done) => Applied::Nothing,
|
||||
Ok(Outcome::Discarded(id)) => {
|
||||
self.closed.retain(|(c, _)| *c != id);
|
||||
Applied::Discarded(id)
|
||||
}
|
||||
Ok(Outcome::Loaded(s, request)) => Applied::Loaded(s, request),
|
||||
Err(message) => {
|
||||
self.error = Some(message);
|
||||
if let Some(id) = &e.discard {
|
||||
// A closed document's copy that can't be deleted stays listed, for the user
|
||||
// to restore or discard.
|
||||
self.closed.retain(|(c, _)| c != id);
|
||||
}
|
||||
match e.document {
|
||||
Some(document) => Applied::Failed { document, discard: e.discard.is_some(), permanent: e.permanent },
|
||||
None => Applied::Nothing,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Queue at most one job: a closed document's discard first, then, document by document in
|
||||
/// the order given, a discard of a copy its document no longer needs (saved, closed without
|
||||
/// saving, password-protected) or a new copy of a dirty document whose interval has passed.
|
||||
pub fn tick<'a>(&mut self, docs: impl Iterator<Item = DocRecovery<'a>>, now: Instant) -> Result<(), String> {
|
||||
if self.busy || !self.ready || !self.alive {
|
||||
return Ok(());
|
||||
}
|
||||
if !s.dirty || s.password.is_some() {
|
||||
if let Some(id) = self.id.clone() {
|
||||
self.queue(Job::Discard(id))?;
|
||||
if let Some(at) = self.closed.iter().position(|(_, queued)| !queued) {
|
||||
let id = self.closed.get(at).map(|(id, _)| id.clone()).ok_or("recovery record list changed")?;
|
||||
self.queue(Job::Discard(id, None))?;
|
||||
if let Some(c) = self.closed.get_mut(at) {
|
||||
c.1 = true;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if self.revision == Some(s.rev()) || now < self.due {
|
||||
for d in docs {
|
||||
if !d.wants_copy {
|
||||
if let Some(id) = d.slot.id.clone()
|
||||
&& d.slot.retry.is_none_or(|t| now >= t)
|
||||
{
|
||||
return self.queue(Job::Discard(id, Some(d.id)));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if d.slot.revision == Some(d.rev) || now < d.slot.due {
|
||||
continue;
|
||||
}
|
||||
let id = match &d.slot.id {
|
||||
Some(id) => id.clone(),
|
||||
None => new_id()?,
|
||||
};
|
||||
let name = d.path.and_then(|p| p.file_name()).map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled draft".into());
|
||||
let info = RecoveryInfo { id: id.clone(), name, saved_at: crate::cmd::now_iso() };
|
||||
self.queue(Job::Save(Box::new(Snapshot { version: 1, info, generation: 0, document: d.doc.clone() }), d.id))?;
|
||||
d.slot.id = Some(id);
|
||||
d.slot.revision = Some(d.rev);
|
||||
d.slot.due = now + INTERVAL;
|
||||
return Ok(());
|
||||
}
|
||||
let id = match &self.id {
|
||||
Some(id) => id.clone(),
|
||||
None => new_id()?,
|
||||
};
|
||||
let name = s.path.as_ref().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled draft".into());
|
||||
let info = RecoveryInfo { id: id.clone(), name, saved_at: crate::cmd::now_iso() };
|
||||
self.queue(Job::Save(Box::new(Snapshot { version: 1, info, generation: 0, document: s.doc.clone() })))?;
|
||||
self.id = Some(id);
|
||||
self.revision = Some(s.rev());
|
||||
self.due = now + INTERVAL;
|
||||
Ok(())
|
||||
}
|
||||
pub fn document_is(&self, document: u64) -> bool {
|
||||
self.document == Some(document)
|
||||
}
|
||||
pub fn wait(&self, document: u64, revision: u64) -> Option<Duration> {
|
||||
if !self.alive || !self.ready || self.busy || (self.document == Some(document) && self.revision == Some(revision)) {
|
||||
/// How long until [`tick`](Self::tick) has work, for a host that repaints only on demand.
|
||||
pub fn wait<'a>(&self, docs: impl Iterator<Item = (bool, u64, &'a Slot)>) -> Option<Duration> {
|
||||
if !self.alive || !self.ready || self.busy {
|
||||
return None;
|
||||
}
|
||||
Some(self.due.saturating_duration_since(Instant::now()).max(Duration::from_millis(100)))
|
||||
let now = Instant::now();
|
||||
let min = Duration::from_millis(100);
|
||||
if self.closed.iter().any(|(_, queued)| !queued) || self.held.as_ref().is_some_and(|(_, r)| !r.replace) {
|
||||
return Some(min);
|
||||
}
|
||||
docs.filter_map(|(wants_copy, rev, slot)| match (wants_copy, &slot.id) {
|
||||
(false, Some(_)) => Some(slot.retry.map_or(min, |t| t.saturating_duration_since(now))),
|
||||
(false, None) => None,
|
||||
(true, _) if slot.revision == Some(rev) => None,
|
||||
(true, _) => Some(slot.due.saturating_duration_since(now)),
|
||||
})
|
||||
.min()
|
||||
.map(|d| d.max(min))
|
||||
}
|
||||
pub fn restore(&mut self, id: &str, expected: (u64, u64)) -> Result<(), String> {
|
||||
pub fn restore(&mut self, id: &str, request: Request) -> Result<(), String> {
|
||||
if !self.documents.iter().any(|d| d.id == id) {
|
||||
return Err("no such recovery copy".into());
|
||||
}
|
||||
self.queue(Job::Load(id.into(), expected))
|
||||
if self.held.is_some() {
|
||||
return Err("another restored copy is waiting for its document".into());
|
||||
}
|
||||
self.queue(Job::Load(id.into(), request))
|
||||
}
|
||||
pub fn discard(&mut self, id: &str, current: (u64, u64)) -> Result<(), String> {
|
||||
pub fn discard(&mut self, id: &str) -> Result<(), String> {
|
||||
if !self.documents.iter().any(|d| d.id == id) {
|
||||
return Err("no such recovery copy".into());
|
||||
}
|
||||
self.queue(Job::Discard(id.into()))?;
|
||||
if self.id.as_deref() == Some(id) {
|
||||
self.revision = Some(current.1);
|
||||
}
|
||||
Ok(())
|
||||
self.queue(Job::Discard(id.into(), None))
|
||||
}
|
||||
pub fn refresh(&mut self) -> Result<(), String> {
|
||||
self.queue(Job::Scan)
|
||||
}
|
||||
pub fn purge(&mut self) -> Result<(), String> {
|
||||
let Some(id) = self.id.clone() else { return Ok(()) };
|
||||
if self.busy {
|
||||
let e = self.rx.recv().map_err(|e| e.to_string())?;
|
||||
self.documents = e.documents;
|
||||
self.busy = false;
|
||||
self.ready = true;
|
||||
if matches!(e.outcome, Ok(Outcome::Loaded(..))) {
|
||||
return Err("finish recovery before protecting this document".into());
|
||||
}
|
||||
}
|
||||
self.queue(Job::Discard(id))?;
|
||||
let e = self.rx.recv().map_err(|e| e.to_string())?;
|
||||
self.busy = false;
|
||||
self.documents = e.documents;
|
||||
e.outcome?;
|
||||
self.id = None;
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Session;
|
||||
use serde_json::json;
|
||||
use wordcraft_doc::StoryRef;
|
||||
|
||||
@@ -707,11 +789,28 @@ mod tests {
|
||||
settle(&mut s);
|
||||
s
|
||||
}
|
||||
/// Every open document's interval has passed.
|
||||
fn due_now(s: &mut Session) {
|
||||
for d in s.recovery_docs() {
|
||||
d.slot.due = Instant::now();
|
||||
}
|
||||
}
|
||||
fn save_now(s: &mut Session) {
|
||||
s.recovery.as_mut().unwrap().due = Instant::now();
|
||||
due_now(s);
|
||||
s.recovery_tick();
|
||||
settle(s);
|
||||
}
|
||||
/// The records in the profile folder: the copies on disk.
|
||||
fn records(root: &Path) -> Vec<String> {
|
||||
let mut ids: Vec<String> = fs::read_dir(root)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().is_dir())
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
ids.sort();
|
||||
ids
|
||||
}
|
||||
|
||||
/// #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
|
||||
@@ -725,17 +824,13 @@ mod tests {
|
||||
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");
|
||||
assert_eq!(records(&profile.0).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"
|
||||
);
|
||||
assert_eq!(records(&profile.0).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");
|
||||
assert!(records(&profile.0).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
|
||||
@@ -749,11 +844,12 @@ mod tests {
|
||||
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_eq!(records(&profile.0).len(), 1, "a recovery copy exists for the dirty document");
|
||||
s.set_document(Document::new()); // the document is replaced, 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");
|
||||
assert_eq!(records(&profile.0).len(), 1, "replacing the document must not discard the previous one's recovery copy");
|
||||
assert_eq!(s.recovery_documents().len(), 1, "no open document keeps it: it is listed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -767,6 +863,8 @@ mod tests {
|
||||
let mut s = session(&profile.0);
|
||||
s.run("document.setText", &json!({"text": "Recoverable synthetic draft"})).unwrap();
|
||||
save_now(&mut s);
|
||||
drop(s);
|
||||
let mut s = session(&profile.0);
|
||||
let id = s.recovery_documents()[0].id.clone();
|
||||
confine(Some(&roots), || {
|
||||
// A command cannot use a file path or choose a different storage folder.
|
||||
@@ -782,9 +880,12 @@ mod tests {
|
||||
assert!(s.path.is_none());
|
||||
assert!(s.dirty);
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Recoverable synthetic draft");
|
||||
s.run("file.recover", &json!({"discard": id})).unwrap();
|
||||
settle(&mut s);
|
||||
// The restored document keeps its copy there: it isn't listed, and can't be discarded.
|
||||
assert!(s.recovery_documents().is_empty());
|
||||
let err = s.run("file.recover", &json!({"discard": id})).unwrap_err();
|
||||
assert!(err.to_string().contains("no such recovery copy"), "{err}");
|
||||
settle(&mut s);
|
||||
assert_eq!(records(&profile.0), std::slice::from_ref(&id));
|
||||
});
|
||||
assert_eq!(fs::read(&original).unwrap(), b"External synthetic original");
|
||||
assert_eq!(fs::read_dir(&files.0).unwrap().count(), 1);
|
||||
@@ -866,7 +967,7 @@ mod tests {
|
||||
save_now(&mut s);
|
||||
assert!(s.recovery_error().is_some());
|
||||
assert!(s.recovery_wait().is_none());
|
||||
s.recovery.as_mut().unwrap().due = Instant::now();
|
||||
due_now(&mut s);
|
||||
s.recovery_tick();
|
||||
assert!(!s.recovery_busy(), "the unchanged document was snapshotted again");
|
||||
assert!(s.recovery_error().is_some());
|
||||
@@ -875,7 +976,7 @@ mod tests {
|
||||
s.run("text.insert", &json!({"text": "!"})).unwrap();
|
||||
save_now(&mut s);
|
||||
assert!(s.recovery_error().is_none());
|
||||
assert_eq!(s.recovery_documents().len(), 1);
|
||||
assert_eq!(records(&t.0).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -957,11 +1058,16 @@ mod tests {
|
||||
settle(&mut s);
|
||||
assert_eq!(s.doc, expected);
|
||||
assert!(s.dirty && s.path.is_none());
|
||||
assert!(s.recovery_documents().is_empty(), "the restored document keeps the copy");
|
||||
// A crash leaves the restored document's copy, in the same record.
|
||||
drop(s);
|
||||
let mut s = session(&t.0);
|
||||
assert_eq!(s.recovery_documents().len(), 1);
|
||||
s.run("file.recover", &json!({"discard": id})).unwrap();
|
||||
settle(&mut s);
|
||||
save_now(&mut s);
|
||||
assert!(s.recovery_documents().is_empty(), "discard isn't undone by the next timer");
|
||||
assert_eq!(s.doc, expected, "discard deletes the copy, not the editing document");
|
||||
assert!(s.recovery_documents().is_empty());
|
||||
assert!(records(&t.0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -987,7 +1093,7 @@ mod tests {
|
||||
let saved = t.0.join("recovered.json");
|
||||
s.run("file.save", &json!({"path": saved})).unwrap();
|
||||
settle(&mut s);
|
||||
assert!(s.recovery_documents().is_empty());
|
||||
assert!(records(&root).is_empty());
|
||||
assert_eq!(fs::read(file).unwrap(), original);
|
||||
}
|
||||
|
||||
@@ -996,18 +1102,18 @@ mod tests {
|
||||
let t = Temp::new();
|
||||
let mut s = session(&t.0);
|
||||
s.run("text.insert", &json!({"text": "Confidential synthetic draft"})).unwrap();
|
||||
s.recovery.as_mut().unwrap().due = Instant::now();
|
||||
due_now(&mut s);
|
||||
s.recovery_tick();
|
||||
assert!(s.recovery_busy());
|
||||
s.run("file.encrypt", &json!({"password": "synthetic-password"})).unwrap();
|
||||
assert!(s.recovery_documents().is_empty());
|
||||
assert!(records(&t.0).is_empty());
|
||||
s.run("text.insert", &json!({"text": "More confidential edits"})).unwrap();
|
||||
save_now(&mut s);
|
||||
assert!(fs::read_dir(&t.0).unwrap().next().is_none());
|
||||
// Removing protection makes future recovery possible again.
|
||||
s.run("file.encrypt", &json!({"password": null})).unwrap();
|
||||
save_now(&mut s);
|
||||
assert_eq!(s.recovery_documents().len(), 1);
|
||||
assert_eq!(records(&t.0).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1016,7 +1122,7 @@ mod tests {
|
||||
let mut s = session(&t.0);
|
||||
s.run("text.insert", &json!({"text": "Synthetic text"})).unwrap();
|
||||
save_now(&mut s);
|
||||
let id = s.recovery_documents()[0].id.clone();
|
||||
let id = records(&t.0).remove(0);
|
||||
let dir = t.0.join(id);
|
||||
// An unknown file prevents removal: don't report that plaintext was purged.
|
||||
let first = Store::files(&dir).unwrap()[0].clone();
|
||||
@@ -1043,6 +1149,31 @@ mod tests {
|
||||
assert!(s.recovery_error().unwrap().contains("document changed"));
|
||||
}
|
||||
|
||||
/// A restore asked for in one document lands in that document, even when another document
|
||||
/// is active while the copy loads.
|
||||
#[test]
|
||||
fn delayed_restore_lands_in_the_document_that_asked_for_it() {
|
||||
let t = Temp::new();
|
||||
let mut s = session(&t.0);
|
||||
s.run("text.insert", &json!({"text": "Saved draft"})).unwrap();
|
||||
save_now(&mut s);
|
||||
drop(s);
|
||||
let mut s = session(&t.0);
|
||||
let first = s.document_id();
|
||||
let id = s.recovery_documents()[0].id.clone();
|
||||
s.run("file.recover", &json!({"restore": id})).unwrap();
|
||||
s.run("file.new", &json!({})).unwrap();
|
||||
let second = s.document_id();
|
||||
s.run("text.insert", &json!({"text": "Other work"})).unwrap();
|
||||
settle(&mut s);
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Other work", "the active document is left alone");
|
||||
s.activate(first).unwrap();
|
||||
settle(&mut s);
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Saved draft");
|
||||
assert_eq!(s.with_document(second, |s| s.doc.plain_text(StoryRef::Body)).unwrap(), "Other work");
|
||||
assert_eq!(s.documents().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_protected_file_and_password_save_never_leave_plaintext_copies() {
|
||||
let t = Temp::new();
|
||||
@@ -1065,7 +1196,7 @@ mod tests {
|
||||
s.run("file.new", &json!({})).unwrap();
|
||||
s.run("text.insert", &json!({"text": "New confidential draft"})).unwrap();
|
||||
save_now(&mut s);
|
||||
assert_eq!(s.recovery_documents().len(), 1);
|
||||
assert_eq!(records(&root).len(), 1);
|
||||
let saved = t.0.join("saved.docx");
|
||||
s.run("file.save", &json!({"path": saved, "password": "synthetic-password"})).unwrap();
|
||||
settle(&mut s);
|
||||
@@ -1073,16 +1204,140 @@ mod tests {
|
||||
assert_eq!(fs::read(original).unwrap(), bytes);
|
||||
}
|
||||
|
||||
/// Each open document keeps its own recovery copy: switching between documents doesn't start
|
||||
/// a new copy of either.
|
||||
#[test]
|
||||
fn each_open_document_keeps_its_own_copy() {
|
||||
let t = Temp::new();
|
||||
let mut s = session(&t.0);
|
||||
let first = s.document_id();
|
||||
s.run("text.insert", &json!({"text": "First synthetic draft"})).unwrap();
|
||||
save_now(&mut s);
|
||||
s.run("file.new", &json!({})).unwrap();
|
||||
settle(&mut s);
|
||||
s.run("text.insert", &json!({"text": "Second synthetic draft"})).unwrap();
|
||||
save_now(&mut s);
|
||||
s.activate(first).unwrap();
|
||||
s.run("text.insert", &json!({"text": ", edited"})).unwrap();
|
||||
save_now(&mut s);
|
||||
assert_eq!(records(&t.0).len(), 2, "one copy per document");
|
||||
assert!(s.recovery_documents().is_empty(), "the copies open documents keep are not listed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protection_after_document_switch_does_not_discard_another_draft() {
|
||||
let t = Temp::new();
|
||||
let mut s = session(&t.0);
|
||||
s.run("text.insert", &json!({"text": "First synthetic draft"})).unwrap();
|
||||
save_now(&mut s);
|
||||
let first = s.document_id();
|
||||
s.run("file.new", &json!({})).unwrap();
|
||||
// Protection can be commanded before the host's next recovery tick.
|
||||
s.run("file.encrypt", &json!({"password": "synthetic-password"})).unwrap();
|
||||
settle(&mut s);
|
||||
assert_eq!(s.recovery_documents().len(), 1);
|
||||
assert!(s.with_document(first, |s| s.recovery_slot.id.is_some()).unwrap());
|
||||
assert_eq!(records(&t.0).len(), 1, "the first draft is kept");
|
||||
}
|
||||
|
||||
/// Two dirty documents, one of them parked since before its first copy: both get copies once
|
||||
/// their interval has passed, without being made active.
|
||||
#[test]
|
||||
fn parked_documents_keep_their_copies() {
|
||||
let t = Temp::new();
|
||||
let mut s = session(&t.0);
|
||||
s.run("text.insert", &json!({"text": "Parked synthetic draft"})).unwrap();
|
||||
let parked = s.document_id();
|
||||
s.run("file.new", &json!({})).unwrap();
|
||||
s.run("text.insert", &json!({"text": "Active synthetic draft"})).unwrap();
|
||||
let active = s.document_id();
|
||||
assert_ne!(parked, active);
|
||||
save_now(&mut s);
|
||||
assert_eq!(s.document_id(), active, "the parked document was not made active");
|
||||
assert_eq!(records(&t.0).len(), 2, "both documents have copies");
|
||||
// An edit to the parked document reaches its copy too.
|
||||
s.with_document(parked, |s| s.run("text.insert", &json!({"text": ", edited"})).unwrap()).unwrap();
|
||||
let id = s.with_document(parked, |s| s.recovery_slot.id.clone()).unwrap().unwrap();
|
||||
save_now(&mut s);
|
||||
drop(s);
|
||||
let mut store = Store::new(t.0.clone()).unwrap();
|
||||
store.scan().unwrap();
|
||||
assert_eq!(load_retrying(&mut store, &id).document.plain_text(StoryRef::Body), "Parked synthetic draft, edited");
|
||||
}
|
||||
|
||||
/// Closing a dirty document and saving another delete both their copies; a third document
|
||||
/// keeps its own.
|
||||
#[test]
|
||||
fn closing_or_saving_a_document_deletes_only_its_copy() {
|
||||
let t = Temp::new();
|
||||
let files = Temp::new();
|
||||
let mut s = session(&t.0);
|
||||
let mut ids = Vec::new();
|
||||
for text in ["Closed draft", "Saved draft", "Kept draft"] {
|
||||
if !ids.is_empty() {
|
||||
s.run("file.new", &json!({})).unwrap();
|
||||
}
|
||||
s.run("text.insert", &json!({"text": text})).unwrap();
|
||||
ids.push(s.document_id());
|
||||
}
|
||||
save_now(&mut s);
|
||||
assert_eq!(records(&t.0).len(), 3);
|
||||
let kept = s.recovery_slot.id.clone().unwrap();
|
||||
s.close_document(ids[0]).unwrap();
|
||||
s.with_document(ids[1], |s| s.run("file.save", &json!({"path": files.0.join("saved.json")})).unwrap()).unwrap();
|
||||
settle(&mut s);
|
||||
assert_eq!(records(&t.0), [kept], "only the open dirty document's copy is left");
|
||||
assert!(s.recovery_documents().is_empty());
|
||||
}
|
||||
|
||||
/// A crash with two dirty documents leaves two copies; the next run lists both and restores
|
||||
/// each into a document of its own, beside the work already there.
|
||||
#[test]
|
||||
fn a_crash_leaves_every_dirty_document_s_copy() {
|
||||
let t = Temp::new();
|
||||
let mut s = session(&t.0);
|
||||
s.run("text.insert", &json!({"text": "First synthetic draft"})).unwrap();
|
||||
s.run("file.new", &json!({})).unwrap();
|
||||
s.run("text.insert", &json!({"text": "Second synthetic draft"})).unwrap();
|
||||
save_now(&mut s);
|
||||
drop(s); // no finish_recovery: the process died
|
||||
let mut s = session(&t.0);
|
||||
let copies = s.recovery_documents();
|
||||
assert_eq!(copies.len(), 2);
|
||||
s.run("text.insert", &json!({"text": "Work in progress"})).unwrap();
|
||||
let work = s.document_id();
|
||||
for copy in &copies {
|
||||
s.run("file.recover", &json!({"restore": copy.id})).unwrap();
|
||||
settle(&mut s);
|
||||
}
|
||||
assert!(s.recovery_documents().is_empty(), "the restored documents keep their copies");
|
||||
let mut texts: Vec<String> = s.documents().iter().map(|d| s.with_document(d.id, |s| s.doc.plain_text(StoryRef::Body)).unwrap()).collect();
|
||||
texts.sort();
|
||||
assert_eq!(texts, ["First synthetic draft", "Second synthetic draft", "Work in progress"]);
|
||||
assert!(s.with_document(work, |s| s.dirty).unwrap());
|
||||
}
|
||||
|
||||
/// A clean exit deletes the copies of documents saved or closed since their last copy, the
|
||||
/// parked ones' too, and keeps the dirty ones'.
|
||||
#[test]
|
||||
fn finish_recovery_covers_every_open_document() {
|
||||
let t = Temp::new();
|
||||
let files = Temp::new();
|
||||
let mut s = session(&t.0);
|
||||
let mut ids = Vec::new();
|
||||
for text in ["Saved draft", "Closed draft", "Dirty draft"] {
|
||||
if !ids.is_empty() {
|
||||
s.run("file.new", &json!({})).unwrap();
|
||||
}
|
||||
s.run("text.insert", &json!({"text": text})).unwrap();
|
||||
ids.push(s.document_id());
|
||||
}
|
||||
save_now(&mut s);
|
||||
assert_eq!(records(&t.0).len(), 3);
|
||||
let dirty = s.recovery_slot.id.clone().unwrap();
|
||||
// No tick between these and the exit.
|
||||
s.with_document(ids[0], |s| s.run("file.save", &json!({"path": files.0.join("saved.json")})).unwrap()).unwrap();
|
||||
s.close_document(ids[1]).unwrap();
|
||||
s.finish_recovery();
|
||||
assert_eq!(records(&t.0), [dirty]);
|
||||
}
|
||||
}
|
||||
|
||||
+751
-93
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,657 @@
|
||||
//! Several open documents in one session. The active document lives in the session's own fields,
|
||||
//! where every command works on it; the others wait as [`DocState`] values and are swapped in,
|
||||
//! field by field, when they become active. Swapping moves the fields: nothing is cloned, laid
|
||||
//! out again or thrown away, so a host may switch documents as often as it likes.
|
||||
|
||||
use std::path::{Path as FsPath, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::{Document, Pos, StoryRef};
|
||||
use wordcraft_layout::LayoutCache;
|
||||
|
||||
use super::{ColumnBlock, FindState, LayoutSlots, MathEdit, Selection, Session, TypedWord, Undo, ViewState, Widened, doc_bib_style};
|
||||
use crate::CmdError;
|
||||
|
||||
/// Document ids are unique in the process, so an id an agent or a window holds never names
|
||||
/// another document, even across sessions.
|
||||
static NEXT_DOCUMENT_ID: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
pub(super) fn next_document_id() -> u64 {
|
||||
NEXT_DOCUMENT_ID.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// The id the next document will get: every document made after this call has an id at least
|
||||
/// this big.
|
||||
pub(super) fn document_id_mark() -> u64 {
|
||||
NEXT_DOCUMENT_ID.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// An untitled document's name: `Document1`, `Document2`…
|
||||
pub(super) fn untitled_name(n: u32) -> String {
|
||||
format!("Document{}", n.max(1))
|
||||
}
|
||||
|
||||
/// An open document that isn't the active one: every per-document field of [`Session`], waiting
|
||||
/// to be swapped back in. The session's app-wide state (clipboard, preferences, macros, Quick
|
||||
/// Parts, the custom dictionary, Read Aloud…) is not here.
|
||||
pub struct DocState {
|
||||
pub(super) doc: Document,
|
||||
pub(super) sel: Selection,
|
||||
pub(super) view: ViewState,
|
||||
pub(super) pending: Option<CharProps>,
|
||||
pub(super) path: Option<PathBuf>,
|
||||
pub(super) file_name: Option<String>,
|
||||
pub(super) dirty: bool,
|
||||
pub(super) pending_move: Option<crate::cmd::moves::PendingMove>,
|
||||
pub(super) find: FindState,
|
||||
pub(super) goal_x: Option<f32>,
|
||||
pub(super) page_hint: usize,
|
||||
pub(super) new_header_parts: Vec<u32>,
|
||||
pub(super) hf_return: Option<Selection>,
|
||||
pub(super) history: Vec<Arc<Undo>>,
|
||||
pub(super) undo_evicted: u64,
|
||||
pub(super) redo: Vec<Arc<Undo>>,
|
||||
pub(super) typing_open: bool,
|
||||
pub(super) join_next: bool,
|
||||
pub(super) equation_join_next: bool,
|
||||
pub(super) ime_text_join: Option<Selection>,
|
||||
pub(super) join_anchor: bool,
|
||||
pub(super) autocap: Option<crate::cmd::tools::AutoCap>,
|
||||
pub(super) rev: u64,
|
||||
pub(super) document_id: u64,
|
||||
pub(super) untitled: u32,
|
||||
pub(super) made_rev: u64,
|
||||
pub(super) ime_body_epoch: u64,
|
||||
pub(super) cache: LayoutCache,
|
||||
pub(super) layout: LayoutSlots,
|
||||
pub(super) typing: Option<TypedWord>,
|
||||
pub(super) originals: std::collections::HashMap<String, String>,
|
||||
pub(super) picture_looks: std::collections::HashMap<String, crate::cmd::objects::PictureLook>,
|
||||
pub(super) versions: Vec<(String, String, Document)>,
|
||||
pub(super) autosave: bool,
|
||||
pub(super) bib_style: String,
|
||||
pub(super) merge: crate::cmd::mailings::MergeState,
|
||||
pub(super) math: Option<MathEdit>,
|
||||
pub(super) password: Option<crate::io::Password>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) recovery_slot: crate::recovery::Slot,
|
||||
pub(super) also_selected: Vec<Pos>,
|
||||
pub(super) column: Option<ColumnBlock>,
|
||||
pub(super) column_mode: bool,
|
||||
pub(super) widened: Option<Widened>,
|
||||
pub(super) edit_marks: Vec<Pos>,
|
||||
pub(super) go_back_step: usize,
|
||||
}
|
||||
|
||||
impl DocState {
|
||||
/// A fresh document state: caret at the start, no history, not dirty, a new id.
|
||||
pub(super) fn new(doc: Document, untitled: u32, view: ViewState) -> Self {
|
||||
let start = doc.start_of(StoryRef::Body);
|
||||
let bib_style = doc_bib_style(&doc);
|
||||
DocState {
|
||||
doc,
|
||||
sel: Selection::caret(start),
|
||||
view,
|
||||
pending: None,
|
||||
path: None,
|
||||
file_name: None,
|
||||
dirty: false,
|
||||
pending_move: None,
|
||||
find: FindState::default(),
|
||||
goal_x: None,
|
||||
page_hint: 0,
|
||||
new_header_parts: Vec::new(),
|
||||
hf_return: None,
|
||||
history: Vec::new(),
|
||||
undo_evicted: 0,
|
||||
redo: Vec::new(),
|
||||
typing_open: false,
|
||||
join_next: false,
|
||||
equation_join_next: false,
|
||||
ime_text_join: None,
|
||||
join_anchor: false,
|
||||
autocap: None,
|
||||
rev: 1,
|
||||
document_id: next_document_id(),
|
||||
untitled,
|
||||
made_rev: 1,
|
||||
ime_body_epoch: 0,
|
||||
cache: LayoutCache::new(),
|
||||
layout: Default::default(),
|
||||
typing: None,
|
||||
originals: Default::default(),
|
||||
picture_looks: Default::default(),
|
||||
versions: Vec::new(),
|
||||
autosave: true,
|
||||
bib_style,
|
||||
merge: Default::default(),
|
||||
math: None,
|
||||
password: None,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
recovery_slot: Default::default(),
|
||||
also_selected: Vec::new(),
|
||||
column: None,
|
||||
column_mode: false,
|
||||
widened: None,
|
||||
edit_marks: Vec::new(),
|
||||
go_back_step: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The document's id ([`Session::document_id`] while it is active).
|
||||
pub fn id(&self) -> u64 {
|
||||
self.document_id
|
||||
}
|
||||
|
||||
fn info(&self) -> DocInfo {
|
||||
DocInfo {
|
||||
id: self.document_id,
|
||||
title: title(self.path.as_deref(), self.file_name.as_deref(), &self.doc, self.untitled),
|
||||
path: self.path.clone(),
|
||||
dirty: self.dirty,
|
||||
active: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One open document, as [`Session::documents`] lists it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DocInfo {
|
||||
/// The document's id: unique in the process, kept while the document is open.
|
||||
pub id: u64,
|
||||
/// The name the title bar shows: the file's name without its extension, else the name given
|
||||
/// with Rename, else the document's Title property, else `Document1`, `Document2`… (English;
|
||||
/// a front end names untitled documents in its own language).
|
||||
pub title: String,
|
||||
pub path: Option<PathBuf>,
|
||||
/// Changed since it was opened or last saved.
|
||||
pub dirty: bool,
|
||||
/// The active document, the one commands work on.
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
fn title(path: Option<&FsPath>, file_name: Option<&str>, doc: &Document, untitled: u32) -> String {
|
||||
match (path, file_name) {
|
||||
(Some(p), _) => p.file_stem().map_or_else(|| p.to_string_lossy().to_string(), |s| s.to_string_lossy().to_string()),
|
||||
(None, Some(name)) => name.to_string(),
|
||||
(None, None) if !doc.core.title.is_empty() => doc.core.title.clone(),
|
||||
(None, None) => untitled_name(untitled),
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Swap the active document's state with `d`'s: `d` then holds the document that was active.
|
||||
fn swap_doc(&mut self, d: &mut DocState) {
|
||||
use std::mem::swap;
|
||||
// Every field is listed, so a new one doesn't build until someone decides whether each
|
||||
// open document has its own (then it goes in `DocState` and is swapped below) or the
|
||||
// session shares it.
|
||||
let Session {
|
||||
// Per document.
|
||||
doc: _,
|
||||
sel: _,
|
||||
view: _,
|
||||
pending: _,
|
||||
path: _,
|
||||
file_name: _,
|
||||
dirty: _,
|
||||
pending_move: _,
|
||||
find: _,
|
||||
goal_x: _,
|
||||
page_hint: _,
|
||||
new_header_parts: _,
|
||||
hf_return: _,
|
||||
history: _,
|
||||
undo_evicted: _,
|
||||
redo: _,
|
||||
typing_open: _,
|
||||
join_next: _,
|
||||
equation_join_next: _,
|
||||
ime_text_join: _,
|
||||
join_anchor: _,
|
||||
autocap: _,
|
||||
rev: _,
|
||||
document_id: _,
|
||||
untitled: _,
|
||||
made_rev: _,
|
||||
ime_body_epoch: _,
|
||||
cache: _,
|
||||
layout: _,
|
||||
typing: _,
|
||||
originals: _,
|
||||
picture_looks: _,
|
||||
versions: _,
|
||||
autosave: _,
|
||||
bib_style: _,
|
||||
merge: _,
|
||||
math: _,
|
||||
password: _,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
recovery_slot: _,
|
||||
also_selected: _,
|
||||
column: _,
|
||||
column_mode: _,
|
||||
widened: _,
|
||||
edit_marks: _,
|
||||
go_back_step: _,
|
||||
// Shared by every open document.
|
||||
user_dict_path: _,
|
||||
clipboard: _,
|
||||
clipboard_text: _,
|
||||
clipboard_html: _,
|
||||
clip_history: _,
|
||||
registry: _,
|
||||
author: _,
|
||||
initials: _,
|
||||
painter: _,
|
||||
status: _,
|
||||
last_command: _,
|
||||
recording: _,
|
||||
macros: _,
|
||||
building_blocks: _,
|
||||
ui_requests: _,
|
||||
read_aloud: _,
|
||||
prefs: _,
|
||||
system_region: _,
|
||||
keymap: _,
|
||||
ribbon: _,
|
||||
editing_lang: _,
|
||||
math_latex: _,
|
||||
math_normal_text: _,
|
||||
last_highlight: _,
|
||||
// Keyed by document id and revision, so one cache serves every document.
|
||||
protect_facts: _,
|
||||
// The input method talks to the window, whichever document it shows; the IME
|
||||
// context is keyed by `document_id`.
|
||||
ime_state_revision: _,
|
||||
ime_state_seen: _,
|
||||
ime_edit_outcome: _,
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
ime_state_exhausted: _,
|
||||
pending_ime_application_change: _,
|
||||
ime_origin: _,
|
||||
others: _,
|
||||
order: _,
|
||||
untitled_count: _,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
recovery: _,
|
||||
} = self;
|
||||
let DocState {
|
||||
doc,
|
||||
sel,
|
||||
view,
|
||||
pending,
|
||||
path,
|
||||
file_name,
|
||||
dirty,
|
||||
pending_move,
|
||||
find,
|
||||
goal_x,
|
||||
page_hint,
|
||||
new_header_parts,
|
||||
hf_return,
|
||||
history,
|
||||
undo_evicted,
|
||||
redo,
|
||||
typing_open,
|
||||
join_next,
|
||||
equation_join_next,
|
||||
ime_text_join,
|
||||
join_anchor,
|
||||
autocap,
|
||||
rev,
|
||||
document_id,
|
||||
untitled,
|
||||
made_rev,
|
||||
ime_body_epoch,
|
||||
cache,
|
||||
layout,
|
||||
typing,
|
||||
originals,
|
||||
picture_looks,
|
||||
versions,
|
||||
autosave,
|
||||
bib_style,
|
||||
merge,
|
||||
math,
|
||||
password,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
recovery_slot,
|
||||
also_selected,
|
||||
column,
|
||||
column_mode,
|
||||
widened,
|
||||
edit_marks,
|
||||
go_back_step,
|
||||
} = d;
|
||||
swap(&mut self.doc, doc);
|
||||
swap(&mut self.sel, sel);
|
||||
swap(&mut self.view, view);
|
||||
swap(&mut self.pending, pending);
|
||||
swap(&mut self.path, path);
|
||||
swap(&mut self.file_name, file_name);
|
||||
swap(&mut self.dirty, dirty);
|
||||
swap(&mut self.pending_move, pending_move);
|
||||
swap(&mut self.find, find);
|
||||
swap(&mut self.goal_x, goal_x);
|
||||
swap(&mut self.page_hint, page_hint);
|
||||
swap(&mut self.new_header_parts, new_header_parts);
|
||||
swap(&mut self.hf_return, hf_return);
|
||||
swap(&mut self.history, history);
|
||||
swap(&mut self.undo_evicted, undo_evicted);
|
||||
swap(&mut self.redo, redo);
|
||||
swap(&mut self.typing_open, typing_open);
|
||||
swap(&mut self.join_next, join_next);
|
||||
swap(&mut self.equation_join_next, equation_join_next);
|
||||
swap(&mut self.ime_text_join, ime_text_join);
|
||||
swap(&mut self.join_anchor, join_anchor);
|
||||
swap(&mut self.autocap, autocap);
|
||||
swap(&mut self.rev, rev);
|
||||
swap(&mut self.document_id, document_id);
|
||||
swap(&mut self.untitled, untitled);
|
||||
swap(&mut self.made_rev, made_rev);
|
||||
swap(&mut self.ime_body_epoch, ime_body_epoch);
|
||||
swap(&mut self.cache, cache);
|
||||
swap(&mut self.layout, layout);
|
||||
swap(&mut self.typing, typing);
|
||||
swap(&mut self.originals, originals);
|
||||
swap(&mut self.picture_looks, picture_looks);
|
||||
swap(&mut self.versions, versions);
|
||||
swap(&mut self.autosave, autosave);
|
||||
swap(&mut self.bib_style, bib_style);
|
||||
swap(&mut self.merge, merge);
|
||||
swap(&mut self.math, math);
|
||||
swap(&mut self.password, password);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
swap(&mut self.recovery_slot, recovery_slot);
|
||||
swap(&mut self.also_selected, also_selected);
|
||||
swap(&mut self.column, column);
|
||||
swap(&mut self.column_mode, column_mode);
|
||||
swap(&mut self.widened, widened);
|
||||
swap(&mut self.edit_marks, edit_marks);
|
||||
swap(&mut self.go_back_step, go_back_step);
|
||||
}
|
||||
|
||||
/// Make document `id` active without anything else a switch by the user does.
|
||||
pub(super) fn switch_to(&mut self, id: u64) -> Result<(), CmdError> {
|
||||
if id == self.document_id {
|
||||
return Ok(());
|
||||
}
|
||||
let at = self.others.iter().position(|d| d.document_id == id).ok_or_else(|| unknown(id))?;
|
||||
let mut d = self.others.swap_remove(at);
|
||||
self.swap_doc(&mut d);
|
||||
self.others.push(d);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Take parked document `id` out of the session; false when it isn't parked here.
|
||||
fn remove_parked(&mut self, id: u64) -> bool {
|
||||
let Some(at) = self.others.iter().position(|d| d.document_id == id) else { return false };
|
||||
let d = self.others.swap_remove(at);
|
||||
self.order.retain(|d| *d != id);
|
||||
self.forget_recovery_copy(&d);
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether document `id` is open in this session (active or not).
|
||||
pub fn is_open(&self, id: u64) -> bool {
|
||||
id == self.document_id || self.others.iter().any(|d| d.document_id == id)
|
||||
}
|
||||
|
||||
/// Every open document in the order they were opened, the active one included.
|
||||
pub fn documents(&self) -> Vec<DocInfo> {
|
||||
self.order
|
||||
.iter()
|
||||
.filter_map(|&id| {
|
||||
if id == self.document_id {
|
||||
Some(DocInfo {
|
||||
id,
|
||||
title: title(self.path.as_deref(), self.file_name.as_deref(), &self.doc, self.untitled),
|
||||
path: self.path.clone(),
|
||||
dirty: self.dirty,
|
||||
active: true,
|
||||
})
|
||||
} else {
|
||||
self.others.iter().find(|d| d.document_id == id).map(DocState::info)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Make document `id` the active one, the one commands work on (nothing happens when it
|
||||
/// already is). A Read Aloud reading of another document stops. An id that isn't open is an
|
||||
/// error.
|
||||
pub fn activate(&mut self, id: u64) -> Result<(), CmdError> {
|
||||
self.switch_to(id)?;
|
||||
self.stop_reading_elsewhere();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Make document `id` the active one, as [`Session::activate`] does, but a Read Aloud
|
||||
/// reading of another document carries on: for a front end that shows each document in a
|
||||
/// window of its own, and makes each window's document active to draw it. An id that isn't
|
||||
/// open is an error.
|
||||
pub fn switch_document(&mut self, id: u64) -> Result<(), CmdError> {
|
||||
self.switch_to(id)
|
||||
}
|
||||
|
||||
/// Open `doc` as another document and make it active; the one that was active stays open.
|
||||
/// The new document starts as a replaced one does ([`Session::set_document`]): caret at the
|
||||
/// start, no undo history, not dirty, no path, an untitled name (`Document2`), and the view
|
||||
/// the active document had. Returns its id.
|
||||
pub fn add_document(&mut self, doc: Document) -> u64 {
|
||||
self.untitled_count = self.untitled_count.saturating_add(1);
|
||||
self.add(doc, self.untitled_count)
|
||||
}
|
||||
|
||||
/// [`Session::add_document`] for a document opened from a file: it doesn't use up an
|
||||
/// untitled name.
|
||||
pub(crate) fn add_opened_document(&mut self, doc: Document) -> u64 {
|
||||
self.add(doc, 0)
|
||||
}
|
||||
|
||||
fn add(&mut self, mut doc: Document, untitled: u32) -> u64 {
|
||||
doc.ensure_nonempty();
|
||||
let mut view = self.view.clone();
|
||||
view.outline.collapsed.clear();
|
||||
let mut d = DocState::new(doc, untitled, view);
|
||||
let id = d.document_id;
|
||||
self.order.push(id);
|
||||
self.swap_doc(&mut d);
|
||||
self.others.push(d);
|
||||
self.stop_reading_elsewhere();
|
||||
id
|
||||
}
|
||||
|
||||
/// A closed document's recovery copy goes with it, as its unsaved changes do.
|
||||
fn forget_recovery_copy(&mut self, d: &DocState) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let (Some(r), Some(id)) = (&mut self.recovery, &d.recovery_slot.id) {
|
||||
r.close(id.clone());
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = d;
|
||||
}
|
||||
|
||||
/// What recovery reads from every open document, the active one first, with each
|
||||
/// document's slot to update. Nothing is cloned or laid out.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn recovery_docs(&mut self) -> impl Iterator<Item = crate::recovery::DocRecovery<'_>> {
|
||||
let active = crate::recovery::DocRecovery {
|
||||
id: self.document_id,
|
||||
wants_copy: self.dirty && self.password.is_none(),
|
||||
rev: self.rev,
|
||||
doc: &self.doc,
|
||||
path: self.path.as_deref(),
|
||||
slot: &mut self.recovery_slot,
|
||||
};
|
||||
std::iter::once(active).chain(self.others.iter_mut().map(|d| crate::recovery::DocRecovery {
|
||||
id: d.document_id,
|
||||
wants_copy: d.dirty && d.password.is_none(),
|
||||
rev: d.rev,
|
||||
doc: &d.doc,
|
||||
path: d.path.as_deref(),
|
||||
slot: &mut d.recovery_slot,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Every open document's recovery slot, with whether it should have a current copy and
|
||||
/// its revision.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn recovery_slots(&self) -> impl Iterator<Item = (bool, u64, &crate::recovery::Slot)> {
|
||||
std::iter::once((self.dirty && self.password.is_none(), self.rev, &self.recovery_slot))
|
||||
.chain(self.others.iter().map(|d| (d.dirty && d.password.is_none(), d.rev, &d.recovery_slot)))
|
||||
}
|
||||
|
||||
/// Close document `id` without asking: its unsaved changes are lost (callers ask first),
|
||||
/// and so is its recovery copy.
|
||||
/// Closing the active document makes the one opened before it active (else the one after).
|
||||
/// Closing the last open document leaves a new blank one, so a session always has an
|
||||
/// active document; what a window does then is the front end's choice. An id that isn't
|
||||
/// open is an error.
|
||||
pub fn close_document(&mut self, id: u64) -> Result<(), CmdError> {
|
||||
if id != self.document_id {
|
||||
if !self.remove_parked(id) {
|
||||
return Err(unknown(id));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let at = self.order.iter().position(|d| *d == id);
|
||||
let next = at.and_then(|i| i.checked_sub(1).and_then(|p| self.order.get(p)).or_else(|| self.order.get(i.saturating_add(1)))).copied();
|
||||
match next {
|
||||
Some(n) => {
|
||||
self.switch_to(n)?;
|
||||
self.remove_parked(id);
|
||||
}
|
||||
None => {
|
||||
let mut doc = crate::cmd::file::normal_document(self);
|
||||
doc.ensure_nonempty();
|
||||
self.untitled_count = self.untitled_count.saturating_add(1);
|
||||
let mut view = self.view.clone();
|
||||
view.outline.collapsed.clear();
|
||||
let mut fresh = DocState::new(doc, self.untitled_count, view);
|
||||
self.order = vec![fresh.document_id];
|
||||
self.swap_doc(&mut fresh);
|
||||
self.forget_recovery_copy(&fresh);
|
||||
}
|
||||
}
|
||||
self.stop_reading_elsewhere();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The active document stands in for no document at all (a Start screen's, or what is left
|
||||
/// after closing the last one): an untouched blank document gives back its untitled name
|
||||
/// when it is the newest, so the first document a person sees is `Document1`, the next
|
||||
/// `Document2`.
|
||||
pub fn release_untitled_name(&mut self) {
|
||||
if self.untitled != 0 && self.untitled == self.untitled_count && self.is_untouched_blank() {
|
||||
self.untitled_count = self.untitled_count.saturating_sub(1);
|
||||
self.untitled = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the active document is a blank one nobody has used: no file and no name, never
|
||||
/// changed, nothing to undo or redo and no text. Opening a file replaces such a document
|
||||
/// rather than opening beside it.
|
||||
pub fn is_untouched_blank(&self) -> bool {
|
||||
self.used_because().is_none()
|
||||
}
|
||||
|
||||
/// Why the active document doesn't count as an untouched blank one, if it doesn't.
|
||||
pub(crate) fn used_because(&self) -> Option<&'static str> {
|
||||
if self.path.is_some() {
|
||||
Some("it has a file")
|
||||
} else if self.file_name.is_some() {
|
||||
Some("it has a name")
|
||||
} else if self.dirty {
|
||||
Some("it has unsaved changes")
|
||||
} else if !self.history.is_empty() || !self.redo.is_empty() {
|
||||
Some("it has undo or redo steps")
|
||||
} else if self.rev != self.made_rev {
|
||||
Some("it changed since it was made")
|
||||
} else if self.doc.paragraph_count() > 1 || !self.doc.plain_text(StoryRef::Body).trim().is_empty() {
|
||||
Some("it has text")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` with document `id` active, then make the document that was active before active
|
||||
/// again (unless `f` closed it). For work addressed to a document other than the one a
|
||||
/// window shows: an agent's command, a result that arrives later. Read Aloud carries on.
|
||||
/// UI requests `f` makes (`ui_requests`) carry `"document": id` when that isn't the
|
||||
/// document that was active. An id that isn't open is an error and `f` doesn't run.
|
||||
pub fn with_document<R>(&mut self, id: u64, f: impl FnOnce(&mut Session) -> R) -> Result<R, CmdError> {
|
||||
let home = self.document_id;
|
||||
self.switch_to(id)?;
|
||||
// The queue drops its oldest entries when full, so `f`'s requests are told apart by
|
||||
// collecting them on their own rather than by position.
|
||||
let earlier = std::mem::take(&mut self.ui_requests);
|
||||
let r = f(self);
|
||||
let made = std::mem::replace(&mut self.ui_requests, earlier);
|
||||
for mut q in made {
|
||||
if id != home
|
||||
&& let Some(o) = q.as_object_mut()
|
||||
{
|
||||
o.entry("document").or_insert(json!(id));
|
||||
}
|
||||
self.push_ui_request(q);
|
||||
}
|
||||
if self.document_id != home && self.is_open(home) {
|
||||
self.switch_to(home)?;
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// The open document saved at `path`, if any: the same file reached by another spelling
|
||||
/// (`./a.docx`, a symbolic link) counts on desktop.
|
||||
pub fn find_open(&self, path: &FsPath) -> Option<u64> {
|
||||
let wanted = crate::io::canonical(path);
|
||||
let same = |p: &FsPath| p == path || wanted.is_some() && crate::io::canonical(p) == wanted;
|
||||
self.order.iter().copied().find(|&id| {
|
||||
if id == self.document_id {
|
||||
self.path.as_deref().is_some_and(same)
|
||||
} else {
|
||||
self.others.iter().find(|d| d.document_id == id).and_then(|d| d.path.as_deref()).is_some_and(same)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Stop a Read Aloud reading of a document that isn't the active one (it highlights its
|
||||
/// sentences in the document shown).
|
||||
fn stop_reading_elsewhere(&mut self) {
|
||||
if self.read_aloud.document.is_some_and(|d| d != self.document_id) && self.read_aloud.state() != crate::speech::State::Stopped {
|
||||
self.read_aloud.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the undo step `run` made for a command back out of the document it started in, when
|
||||
/// the command ended up in another document (it opened or made one) and left the first
|
||||
/// document's text as it was: opening a document is no edit to undo there.
|
||||
pub(super) fn put_back_parked(&mut self, id: u64, before: Option<super::RunSnapshot>) {
|
||||
let Some((doc, sel, history, redo, typing_open, evicted, column)) = before else { return };
|
||||
if !self.is_open(id) {
|
||||
return;
|
||||
}
|
||||
let _ = self.with_document(id, |s| {
|
||||
if s.doc != doc {
|
||||
return;
|
||||
}
|
||||
s.sel = sel;
|
||||
s.history = history;
|
||||
s.redo = redo;
|
||||
s.typing_open = typing_open;
|
||||
s.undo_evicted = evicted;
|
||||
s.column = column;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn unknown(id: u64) -> CmdError {
|
||||
CmdError::Params(format!("no open document has id {id}"))
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
//! Several windows on one document. The document, its undo history, dirty flag, path and save
|
||||
//! state are shared; each window has its own [`WindowView`]: where its caret is and how it shows
|
||||
//! the document. The session holds one view per document (the drawn window's, for the active
|
||||
//! document); a front end keeps the other windows' views and moves them in and out with
|
||||
//! [`Session::take_view`] and [`Session::put_view`] as it draws each window.
|
||||
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::{Pos, StoryRef};
|
||||
|
||||
use super::{ColumnBlock, MathEdit, Selection, Session, TypedWord, ViewState, Widened, next_math_session};
|
||||
|
||||
/// One window's view of a document: its selection and caret state, and how it shows the page
|
||||
/// (zoom, view mode, panes, ruler…). Positions in it may be stale after edits made through
|
||||
/// another window; [`Session::put_view`] makes them valid again.
|
||||
pub struct WindowView {
|
||||
sel: Selection,
|
||||
view: ViewState,
|
||||
pending: Option<CharProps>,
|
||||
goal_x: Option<f32>,
|
||||
page_hint: usize,
|
||||
hf_return: Option<Selection>,
|
||||
typing_open: bool,
|
||||
join_next: bool,
|
||||
equation_join_next: bool,
|
||||
ime_text_join: Option<Selection>,
|
||||
typing: Option<TypedWord>,
|
||||
math: Option<MathEdit>,
|
||||
also_selected: Vec<Pos>,
|
||||
column: Option<ColumnBlock>,
|
||||
column_mode: bool,
|
||||
widened: Option<Widened>,
|
||||
/// The document and its revision when the view was taken out.
|
||||
document_id: u64,
|
||||
rev: u64,
|
||||
}
|
||||
|
||||
impl WindowView {
|
||||
/// The document this view is of.
|
||||
pub fn document_id(&self) -> u64 {
|
||||
self.document_id
|
||||
}
|
||||
|
||||
/// The view's selection.
|
||||
pub fn selection(&self) -> &Selection {
|
||||
&self.sel
|
||||
}
|
||||
|
||||
/// How the view shows the document.
|
||||
pub fn view(&self) -> &ViewState {
|
||||
&self.view
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Take the active document's view out of the session, for a window that stops being drawn.
|
||||
/// The session is left with a caret at the start of the document and the default view
|
||||
/// until [`Session::put_view`] puts a view back.
|
||||
pub fn take_view(&mut self) -> WindowView {
|
||||
use std::mem::{replace, take};
|
||||
let start = self.doc.start_of(StoryRef::Body);
|
||||
WindowView {
|
||||
sel: replace(&mut self.sel, Selection::caret(start)),
|
||||
view: take(&mut self.view),
|
||||
pending: self.pending.take(),
|
||||
goal_x: self.goal_x.take(),
|
||||
page_hint: take(&mut self.page_hint),
|
||||
hf_return: self.hf_return.take(),
|
||||
typing_open: take(&mut self.typing_open),
|
||||
join_next: take(&mut self.join_next),
|
||||
equation_join_next: take(&mut self.equation_join_next),
|
||||
ime_text_join: self.ime_text_join.take(),
|
||||
typing: self.typing.take(),
|
||||
math: self.math.take(),
|
||||
also_selected: take(&mut self.also_selected),
|
||||
column: self.column.take(),
|
||||
column_mode: take(&mut self.column_mode),
|
||||
widened: self.widened.take(),
|
||||
document_id: self.document_id,
|
||||
rev: self.rev,
|
||||
}
|
||||
}
|
||||
|
||||
/// A copy of the active document's view, for a new window on it (View › New Window).
|
||||
pub fn copy_view(&self) -> WindowView {
|
||||
WindowView {
|
||||
sel: self.sel.clone(),
|
||||
view: self.view.clone(),
|
||||
pending: self.pending.clone(),
|
||||
goal_x: self.goal_x,
|
||||
page_hint: self.page_hint,
|
||||
hf_return: self.hf_return.clone(),
|
||||
// The new window hasn't typed: its first key starts an undo step of its own.
|
||||
typing_open: false,
|
||||
join_next: false,
|
||||
equation_join_next: false,
|
||||
ime_text_join: None,
|
||||
typing: None,
|
||||
// An equation editing session is an input method's owner: the new window's is its own.
|
||||
math: self.math.clone().and_then(|m| Some(MathEdit { session: next_math_session()?, ..m })),
|
||||
also_selected: self.also_selected.clone(),
|
||||
column: self.column.clone(),
|
||||
column_mode: self.column_mode,
|
||||
widened: self.widened.clone(),
|
||||
document_id: self.document_id,
|
||||
rev: self.rev,
|
||||
}
|
||||
}
|
||||
|
||||
/// Make `v` the active document's view. When the document changed since `v` was taken
|
||||
/// (another window edited it, or `v` is of another document), its positions are clamped into
|
||||
/// the document, what only made sense for the text it was taken from (a column block, a
|
||||
/// widened table selection, more selected objects, the word being typed) is dropped, and the
|
||||
/// window's typing no longer joins its last undo step.
|
||||
pub fn put_view(&mut self, v: WindowView) {
|
||||
let WindowView {
|
||||
sel,
|
||||
view,
|
||||
pending,
|
||||
goal_x,
|
||||
page_hint,
|
||||
hf_return,
|
||||
typing_open,
|
||||
join_next,
|
||||
equation_join_next,
|
||||
ime_text_join,
|
||||
typing,
|
||||
math,
|
||||
also_selected,
|
||||
column,
|
||||
column_mode,
|
||||
widened,
|
||||
document_id,
|
||||
rev,
|
||||
} = v;
|
||||
let current = document_id == self.document_id && rev == self.rev;
|
||||
self.sel = sel;
|
||||
self.view = view;
|
||||
self.pending = pending;
|
||||
self.goal_x = goal_x;
|
||||
self.page_hint = page_hint;
|
||||
self.column_mode = column_mode;
|
||||
self.math = math;
|
||||
if current {
|
||||
self.hf_return = hf_return;
|
||||
self.typing_open = typing_open;
|
||||
self.join_next = join_next;
|
||||
self.equation_join_next = equation_join_next;
|
||||
self.ime_text_join = ime_text_join;
|
||||
self.typing = typing;
|
||||
self.also_selected = also_selected;
|
||||
self.column = column;
|
||||
self.widened = widened;
|
||||
return;
|
||||
}
|
||||
self.hf_return = hf_return.map(|s| Selection { anchor: self.doc.clamp(&s.anchor), focus: self.doc.clamp(&s.focus) });
|
||||
self.typing_open = false;
|
||||
self.join_next = false;
|
||||
self.equation_join_next = false;
|
||||
self.ime_text_join = None;
|
||||
self.typing = None;
|
||||
self.also_selected = Vec::new();
|
||||
self.column = None;
|
||||
self.widened = None;
|
||||
self.goal_x = None;
|
||||
self.clamp_selection();
|
||||
}
|
||||
}
|
||||
+112
-11
@@ -25,6 +25,9 @@ use wordcraft_doc::{Document, InlineObject, Pos};
|
||||
|
||||
/// Longest sentence handed to the speech program (longer ones are split at a space).
|
||||
const MAX_SENTENCE: usize = 600;
|
||||
/// Longest utterance, even with no space to split at (a long URL, a run of letters): it is cut
|
||||
/// there, mid-word.
|
||||
const MAX_UTTERANCE: usize = 2 * MAX_SENTENCE;
|
||||
/// Most sentences in one queue.
|
||||
const MAX_SENTENCES: usize = 20_000;
|
||||
/// Speed limits (1.0 = the normal speed).
|
||||
@@ -72,7 +75,8 @@ impl Utterance {
|
||||
}
|
||||
|
||||
/// The sentences from `from` (to `to`, if given) in `from`'s story, in reading order.
|
||||
/// With `skip_citations`, citation and bibliography fields are left out.
|
||||
/// With `skip_citations`, citation and bibliography fields are left out. At most
|
||||
/// [`MAX_SENTENCES`]; when that cuts the text short a warning is logged, so the stop is not silent.
|
||||
pub fn sentences(doc: &Document, from: &Pos, to: Option<&Pos>, skip_citations: bool) -> Vec<Utterance> {
|
||||
let story = from.story;
|
||||
// Spans to leave out: whole range fields, both markers included.
|
||||
@@ -128,12 +132,16 @@ pub fn sentences(doc: &Document, from: &Pos, to: Option<&Pos>, skip_citations: b
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sliced per sentence; skipping from the start each time was quadratic in the paragraph.
|
||||
let text_chars: Vec<char> = text.chars().collect();
|
||||
for (a, b) in split_sentences(&text) {
|
||||
if out.len() >= MAX_SENTENCES {
|
||||
log::warn!(
|
||||
"Read Aloud: the queue holds at most {MAX_SENTENCES} sentences; the rest of the text is not read (start again from where it stops)"
|
||||
);
|
||||
return out;
|
||||
}
|
||||
let chars: Vec<char> = text.chars().skip(a).take(b - a).collect();
|
||||
let spoken = tidy(&chars.iter().collect::<String>());
|
||||
let spoken = tidy(&text_chars.get(a..b).unwrap_or(&[]).iter().collect::<String>());
|
||||
if !spoken.chars().any(char::is_alphanumeric) {
|
||||
continue;
|
||||
}
|
||||
@@ -426,6 +434,14 @@ fn push_trimmed(chars: &[char], mut a: usize, mut b: usize, out: &mut Vec<(usize
|
||||
while b > a && chars.get(b - 1).is_some_and(|c| c.is_whitespace()) {
|
||||
b -= 1;
|
||||
}
|
||||
// No space to split at (a long URL, a run of letters or full stops): cut the span hard.
|
||||
while b - a > MAX_UTTERANCE {
|
||||
out.push((a, a + MAX_UTTERANCE));
|
||||
a += MAX_UTTERANCE;
|
||||
while a < b && chars.get(a).is_some_and(|c| c.is_whitespace()) {
|
||||
a += 1;
|
||||
}
|
||||
}
|
||||
if a < b {
|
||||
out.push((a, b));
|
||||
}
|
||||
@@ -450,18 +466,39 @@ fn is_abbreviation(chars: &[char], start: usize, dot: usize) -> bool {
|
||||
}
|
||||
|
||||
/// Collapse whitespace and tidy what removed citations leave behind: "( )", "[]", " ." → ".".
|
||||
/// One pass, linear in `s` (#3680): a closing bracket right after its opening one (or after it
|
||||
/// and a space) removes the pair, so nested empty brackets "( [ ] )" go too.
|
||||
fn tidy(s: &str) -> String {
|
||||
let mut t: String = s.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
loop {
|
||||
let before = t.len();
|
||||
for (from, to) in [("()", ""), ("[]", ""), ("( )", ""), ("[ ]", ""), (" .", "."), (" ,", ","), (" ;", ";"), (" :", ":"), (" ", " ")] {
|
||||
t = t.replace(from, to);
|
||||
let mut t = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
if c.is_whitespace() {
|
||||
if !t.is_empty() && !t.ends_with(' ') {
|
||||
t.push(' ');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if t.len() == before {
|
||||
break;
|
||||
let open = match c {
|
||||
')' => Some('('),
|
||||
']' => Some('['),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(open) = open {
|
||||
let body = t.strip_suffix(' ').unwrap_or(&t);
|
||||
if let Some(rest) = body.strip_suffix(open) {
|
||||
let keep = rest.len();
|
||||
t.truncate(keep);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if matches!(c, '.' | ',' | ';' | ':') && t.ends_with(' ') {
|
||||
t.pop();
|
||||
}
|
||||
t.push(c);
|
||||
}
|
||||
t.trim().to_string()
|
||||
if t.ends_with(' ') {
|
||||
t.pop();
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// Reading state.
|
||||
@@ -514,6 +551,9 @@ pub struct ReadAloud {
|
||||
pub skip_citations: bool,
|
||||
/// The player window is open (it stays open after the last sentence until closed).
|
||||
pub open: bool,
|
||||
/// The document being read ([`crate::Session::document_id`]): the sentences' positions are
|
||||
/// in it.
|
||||
pub document: Option<u64>,
|
||||
}
|
||||
|
||||
impl Default for ReadAloud {
|
||||
@@ -527,6 +567,7 @@ impl Default for ReadAloud {
|
||||
backend: if off { Backend::Hold } else { Backend::System },
|
||||
skip_citations: true,
|
||||
open: false,
|
||||
document: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -926,10 +967,70 @@ mod tests {
|
||||
assert!(spans.iter().all(|(a, b)| b - a <= MAX_SENTENCE + 5));
|
||||
}
|
||||
|
||||
/// A word with no space to split at, or a run of punctuation, is still cut into utterances of
|
||||
/// at most `MAX_UTTERANCE` characters, covering all of it (#3680).
|
||||
#[test]
|
||||
fn sentences_without_spaces_are_split() {
|
||||
for t in ["x".repeat(10_000), "!".repeat(10_000), format!("Go {} now.", "a.".repeat(5_000))] {
|
||||
let spans = split_sentences(&t);
|
||||
assert!(spans.len() >= 8, "{} spans", spans.len());
|
||||
assert!(spans.iter().all(|(a, b)| a < b && b - a <= MAX_UTTERANCE));
|
||||
assert_eq!(spans.first().map(|s| s.0), Some(0));
|
||||
assert_eq!(spans.last().map(|s| s.1), Some(t.chars().count()));
|
||||
assert!(spans.windows(2).all(|w| w[0].1 <= w[1].0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tidy_removes_citation_leftovers() {
|
||||
assert_eq!(tidy("as shown ( ) ."), "as shown.");
|
||||
assert_eq!(tidy("as shown [] , and"), "as shown, and");
|
||||
assert_eq!(tidy(" a ( ( [ ] ) ) b ( x ) [ ) ] ; "), "a b ( x ) [ ) ];");
|
||||
}
|
||||
|
||||
/// The tidy that ran replacements until nothing changed, kept to check the one-pass tidy gives
|
||||
/// the same text.
|
||||
fn tidy_by_replacing(s: &str) -> String {
|
||||
let mut t: String = s.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
loop {
|
||||
let before = t.len();
|
||||
for (from, to) in [("()", ""), ("[]", ""), ("( )", ""), ("[ ]", ""), (" .", "."), (" ,", ","), (" ;", ";"), (" :", ":"), (" ", " ")] {
|
||||
t = t.replace(from, to);
|
||||
}
|
||||
if t.len() == before {
|
||||
break;
|
||||
}
|
||||
}
|
||||
t.trim().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tidy_matches_replacing_until_nothing_changes() {
|
||||
let alphabet = ['(', ')', '[', ']', ' ', ' ', '\t', '\u{a0}', '.', ',', ';', ':', 'a', 'é'];
|
||||
let mut seed: u64 = 0x3680;
|
||||
for _ in 0..20_000 {
|
||||
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
|
||||
let len = (seed >> 59) as usize + 1;
|
||||
let s: String = (0..len)
|
||||
.map(|i| {
|
||||
let r = seed.rotate_left(5 * i as u32 + 1) ^ (i as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15);
|
||||
alphabet[(r % alphabet.len() as u64) as usize]
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(tidy(&s), tidy_by_replacing(&s), "{s:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Nested empty brackets are removed in one pass: 40,000 pairs took seconds when each pass
|
||||
/// removed one level (#3680).
|
||||
#[test]
|
||||
fn tidy_is_linear_on_nested_brackets() {
|
||||
let n = 40_000;
|
||||
let started = std::time::Instant::now();
|
||||
assert_eq!(tidy(&format!("a {}{} b", "( [".repeat(n), "] )".repeat(n))), "a b");
|
||||
assert_eq!(tidy(&format!("{}x{}", "(".repeat(n), ")".repeat(n))).len(), 2 * n + 1);
|
||||
// Milliseconds here; `tidy_by_replacing` (the old tidy) takes about 5 minutes on this input.
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+566
-11
@@ -56,6 +56,30 @@ fn encrypted_doc_asks_for_a_password_and_rejects_a_wrong_one() {
|
||||
assert!(crate::io::needs_password(&e), "{e}");
|
||||
}
|
||||
|
||||
/// Gap 9: AES-encrypted Word files — Standard encryption (Word 2007) and agile packages — open
|
||||
/// with the right password whatever Word extension they carry, and ask for one or refuse a wrong
|
||||
/// one the same way.
|
||||
#[test]
|
||||
fn aes_encrypted_packages_open_under_any_word_extension() {
|
||||
let mut doc = wordcraft_doc::Document::from_text("Old secret minutes");
|
||||
doc.core.title = "Minutes".into();
|
||||
let pkg = wordcraft_docx::write(&doc).unwrap();
|
||||
let standard = wordcraft_docx::encrypt_standard_for_tests(&pkg, "letmein", 128).unwrap();
|
||||
let agile = wordcraft_docx::encrypt_with_spin_count(&pkg, "letmein", 8).unwrap();
|
||||
for (kind, bytes) in [("standard", &standard), ("agile", &agile)] {
|
||||
for name in ["old.doc", "old.docx", "old.dot", "noext"] {
|
||||
let (d, encrypted) = crate::io::open_bytes_with(name, bytes, Some("letmein")).unwrap_or_else(|e| panic!("{kind} {name}: {e}"));
|
||||
assert!(encrypted, "{kind} {name}");
|
||||
assert!(d.plain_text(StoryRef::Body).contains("Old secret minutes"), "{kind} {name}");
|
||||
assert_eq!(d.core.title, "Minutes");
|
||||
let e = crate::io::open_bytes_with(name, bytes, None).unwrap_err();
|
||||
assert!(crate::io::needs_password(&e), "{kind} {name}: {e}");
|
||||
let e = crate::io::open_bytes_with(name, bytes, Some("letmeout")).unwrap_err();
|
||||
assert!(crate::io::wrong_password(&e), "{kind} {name}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn txt_open_decodes_windows_1252_and_unmarked_utf16() {
|
||||
// #433: the TXT open path sniffs encodings like the formats importer.
|
||||
@@ -224,6 +248,33 @@ fn custom_dictionary_words_persist_through_their_file() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confined_sessions_neither_save_the_dictionary_nor_choose_folders_outside_their_roots() {
|
||||
// #3737 (findings 34 and WC-35): the custom dictionary file and the folder settings are
|
||||
// held to the automation roots like any other path.
|
||||
let base = scratch_dir("dict-roots");
|
||||
for d in ["in", "out", "outside"] {
|
||||
std::fs::create_dir_all(base.join(d)).unwrap();
|
||||
}
|
||||
let roots = crate::file_access::AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap();
|
||||
let mut s = s();
|
||||
let outside = base.join("outside/custom-dictionary.txt");
|
||||
s.user_dict_path = Some(outside.clone());
|
||||
let confined = |s: &mut Session, cmd: &str, v: serde_json::Value| crate::file_access::confine(roots.as_ref(), || s.run(cmd, &v));
|
||||
assert!(confined(&mut s, "review.addToDictionary", json!({"word": "zxqengconfined"})).is_ok());
|
||||
assert!(wordcraft_proof::user_dictionary().contains(&"zxqengconfined".to_string()), "kept in memory");
|
||||
assert!(!outside.exists(), "nothing written outside the write root");
|
||||
for key in ["dictionariesFolder", "userTemplatesFolder"] {
|
||||
assert!(confined(&mut s, "file.options", json!({key: base.join("outside").to_string_lossy()})).is_err(), "{key}");
|
||||
}
|
||||
assert_eq!((s.prefs.dictionaries_folder.as_deref(), s.prefs.user_templates_folder.as_deref()), (None, None));
|
||||
// A folder inside the read root is still taken.
|
||||
let inside = base.join("in");
|
||||
assert!(confined(&mut s, "file.options", json!({"userTemplatesFolder": inside.to_string_lossy()})).is_ok());
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
assert_eq!(s.prefs.user_templates_folder.as_deref(), Some(inside.to_string_lossy().as_ref()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_to_dictionary_takes_only_words_the_file_keeps() {
|
||||
// #40: the same rule as loading the dictionary file, so nothing added is dropped on the next load.
|
||||
@@ -892,6 +943,72 @@ fn find_replace_wildcards() {
|
||||
assert_eq!(run(&mut s, "edit.find", json!({"text": "eg", "matchSuffix": false, "ignorePunct": true}))["count"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_replace_expands_groups() {
|
||||
// #4177: with Regular expressions on, Replace with expands capture groups.
|
||||
let rep = |text: &str, q: &str, with: &str| rep_mode(text, q, with, json!({"regex": true}));
|
||||
let names = "Smith, John and Doe, Jane";
|
||||
// The issue's cases: `\n` and `$n` swap words; `${name}` names a group.
|
||||
assert_eq!(rep(names, r"(\w+), (\w+)", r"\2 \1"), "John Smith and Jane Doe");
|
||||
assert_eq!(rep(names, r"(\w+), (\w+)", "$2 $1"), "John Smith and Jane Doe");
|
||||
assert_eq!(rep("2024-05", r"(?<y>\d{4})-", "${y}/"), "2024/05");
|
||||
// Braced numbers, `$0` / `${0}` (whole match), `$$` (a dollar sign).
|
||||
assert_eq!(rep("ab", "(a)(b)", "${2}${1}"), "ba");
|
||||
assert_eq!(rep("ab", "a", "[$0|${0}]"), "[a|a]b");
|
||||
assert_eq!(rep("5", r"(\d)", "$$$1"), "$5");
|
||||
// `$name` takes the longest run of letters, digits and `_` (the regex crate's rule).
|
||||
assert_eq!(rep("ab", "(?<x>a)", "$x_"), "b");
|
||||
assert_eq!(rep("ab", "(?<x>a)", "${x}_"), "a_b");
|
||||
assert_eq!(rep("ab", "(a)", "$1a"), "b");
|
||||
// Missing, unknown and out-of-range groups expand to nothing.
|
||||
assert_eq!(rep("ab", "(a)", "<$2>"), "<>b");
|
||||
assert_eq!(rep("ab", "(a)", r"<\9>"), "<>b");
|
||||
assert_eq!(rep("ab", "(a)", "<${nope}>"), "<>b");
|
||||
assert_eq!(rep("ab", "(a)", "<$99999999999999999999999>"), "<>b");
|
||||
assert_eq!(rep("ab", "(a)|(z)", "<$2>"), "<>b");
|
||||
// A `$` that starts no group, and an unclosed brace, stay as written.
|
||||
assert_eq!(rep("ab", "a", "$ ${x"), "$ ${xb");
|
||||
// `\t` a tab, `\\` a backslash; other escapes stay as written.
|
||||
assert_eq!(rep("ab", "a", r"\t\\\q"), "\t\\\\qb");
|
||||
// Word's `^` codes still work.
|
||||
assert_eq!(rep("ab", "a", "^&^t^&"), "a\tab");
|
||||
// `\$` and `\^` keep the character as typed (CodeRabbit on #4177).
|
||||
assert_eq!(rep("ab", "(a)", r"\$1\^p"), "$1^pb");
|
||||
// `special: false` turns off the `^` codes only; groups still expand (CodeRabbit on #4177).
|
||||
assert_eq!(rep_mode("ab", "(a)", "$1^t", json!({"regex": true, "special": false})), "a^tb");
|
||||
// `\n` is a paragraph mark: it splits the paragraph, as `^p` does, in one undo step.
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "one, two"}));
|
||||
assert_eq!(run(&mut s, "edit.replaceAll", json!({"text": r"(\w+), (\w+)", "with": r"$1\n$2", "regex": true}))["replaced"], 1);
|
||||
assert_eq!(text(&s), "one\ntwo");
|
||||
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(text(&s), "one, two");
|
||||
run(&mut s, "document.setText", json!({"text": "a-b"}));
|
||||
run(&mut s, "edit.replaceAll", json!({"text": "-", "with": "^p", "regex": true}));
|
||||
assert_eq!(s.doc.para_paths(StoryRef::Body).len(), 2);
|
||||
// Plain and wildcard Replace are unchanged: `$1` is literal there.
|
||||
assert_eq!(rep_mode("ab", "a", "$1", json!({})), "$1b");
|
||||
assert_eq!(rep_mode("ab", "(a)", "$1\\1", json!({"wildcards": true})), "$1ab");
|
||||
// Hostile Replace text never panics.
|
||||
run(&mut s, "document.setText", json!({"text": "ab ab"}));
|
||||
for with in ["$", "${", "${}", "${0", "$\u{e9}", "\\", "${\u{1F600}}", "$18446744073709551616", "${-1}", "$$$", "\\0\\n\\", "^"] {
|
||||
let _ = s.run("edit.replaceAll", &json!({"text": "(a)(?<n>b)?", "with": with, "regex": true}));
|
||||
}
|
||||
}
|
||||
|
||||
/// The body text after Replace All of `q` with `with` in `text`, matching case, with `extra` options.
|
||||
fn rep_mode(text: &str, q: &str, with: &str, extra: serde_json::Value) -> String {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": text}));
|
||||
let mut v = json!({"text": q, "with": with, "matchCase": true});
|
||||
if let (Some(o), Some(e)) = (v.as_object_mut(), extra.as_object()) {
|
||||
o.extend(e.clone());
|
||||
}
|
||||
run(&mut s, "edit.replaceAll", v);
|
||||
self::text(&s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_find_patterns_are_bounded() {
|
||||
let mut s = s();
|
||||
@@ -3864,6 +3981,36 @@ fn failed_command_leaves_document_unchanged() {
|
||||
assert_eq!(text(&s), before);
|
||||
}
|
||||
|
||||
/// Review of #3692: the line cap on typed paragraphs is the engine's, so a control-channel or
|
||||
/// CLI `text.insert` is held to it as the MCP tool is. At the cap every line goes in as one undo
|
||||
/// step; one line more is refused before anything is typed.
|
||||
#[test]
|
||||
fn typed_paragraphs_are_capped_for_every_caller() {
|
||||
use crate::CmdError;
|
||||
use crate::cmd::text::MAX_TYPED_PARAGRAPHS;
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "keep"}));
|
||||
let (before, depth) = (body_texts(&s), s.undo_depth());
|
||||
// "x\n" repeated ends in an empty line: one over the cap. A lone "\r" counts as a break too.
|
||||
for over in ["x\n".repeat(MAX_TYPED_PARAGRAPHS), format!("{}x", "x\r\n".repeat(MAX_TYPED_PARAGRAPHS - 1)) + "\rx"] {
|
||||
match s.run("text.insert", &json!({"text": over, "paragraphs": true})) {
|
||||
Err(CmdError::Params(m)) => assert!(m.contains("10001 lines") && m.contains("at most 10000"), "{m}"),
|
||||
r => panic!("not refused: {r:?}"),
|
||||
}
|
||||
assert_eq!(body_texts(&s), before, "nothing typed");
|
||||
assert_eq!(s.undo_depth(), depth, "no undo step");
|
||||
}
|
||||
let at_cap = vec!["y"; MAX_TYPED_PARAGRAPHS].join("\r\n");
|
||||
run(&mut s, "text.insert", json!({"text": at_cap, "paragraphs": true}));
|
||||
let paras = body_texts(&s);
|
||||
assert_eq!(paras.len(), MAX_TYPED_PARAGRAPHS);
|
||||
// Each break is Enter's own, AutoCorrect included: the first sentence gets its capital.
|
||||
assert_eq!((paras.first().map(String::as_str), paras.last().map(String::as_str)), (Some("Keepy"), Some("y")));
|
||||
assert!(s.undo(), "undo");
|
||||
assert_eq!(body_texts(&s).len(), 1, "one undo step takes every line back out");
|
||||
assert!(!body_texts(&s).iter().any(|p| p.contains('y')));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_params_never_panic() {
|
||||
let reg = cmd::registry();
|
||||
@@ -4421,6 +4568,21 @@ fn word_count_with_an_object_selected_counts_the_document() {
|
||||
assert_eq!(run(&mut s, "review.wordCount", json!({}))["words"].as_u64(), Some(1));
|
||||
}
|
||||
|
||||
/// #2982: a property imported from a file made by an older Word may have a name longer than
|
||||
/// today's limit. Such a name is still deletable (the dialog sends `null`), while setting or
|
||||
/// renaming a property to it stays refused.
|
||||
#[test]
|
||||
fn custom_properties_delete_legacy_names_but_new_names_keep_the_limit() {
|
||||
let mut s = s();
|
||||
let long = "x".repeat(crate::cmd::file::MAX_CUSTOM_NAME + 1);
|
||||
s.doc.custom_props.push(wordcraft_doc::CustomProp { name: long.clone(), kind: "raw".into(), value: "<data/>".into() });
|
||||
let mut custom = serde_json::Map::new();
|
||||
custom.insert(long.clone(), serde_json::Value::Null);
|
||||
run(&mut s, "file.properties", json!({"custom": custom}));
|
||||
assert!(s.doc.custom_props.is_empty(), "the legacy name could be deleted");
|
||||
assert!(s.run("file.properties", &json!({"custom": { (long): "x" }})).is_err(), "a new or updated name keeps the limit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_properties_set_read_remove_and_undo() {
|
||||
let mut s = s();
|
||||
@@ -4438,6 +4600,37 @@ fn custom_properties_set_read_remove_and_undo() {
|
||||
assert_eq!(s.doc.custom_prop("Status"), Some("draft"));
|
||||
}
|
||||
|
||||
/// #2982: a case-only rename arrives as the old spelling with null and the new one with its
|
||||
/// value. The removal must run first: object keys are sorted, so `Status` sorts before
|
||||
/// `status`, and setting before removing would let the removal take the new spelling too.
|
||||
#[test]
|
||||
fn custom_properties_case_only_rename_keeps_the_new_spelling() {
|
||||
let mut s = s();
|
||||
run(&mut s, "file.properties", json!({"custom": {"status": "draft"}}));
|
||||
run(&mut s, "file.properties", json!({"custom": {"Status": "draft", "status": null}}));
|
||||
let props: Vec<(&str, &str)> = s.doc.custom_props.iter().map(|p| (p.name.as_str(), p.value.as_str())).collect();
|
||||
assert_eq!(props, [("Status", "draft")], "the property keeps the new spelling and its value");
|
||||
}
|
||||
|
||||
/// #2982: what the Custom Properties dialog saves (through `file.properties`) survives a .docx
|
||||
/// save and reopen.
|
||||
#[test]
|
||||
fn custom_properties_round_trip_through_docx() {
|
||||
let mut s = s();
|
||||
run(&mut s, "file.properties", json!({"custom": {"Status": "draft", "Reviewer": "Ada L."}}));
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
|
||||
assert_eq!(back.custom_prop("Status"), Some("draft"));
|
||||
assert_eq!(back.custom_prop("Reviewer"), Some("Ada L."));
|
||||
assert_eq!(back.custom_props.len(), 2);
|
||||
// One call removes one and adds another; the saved file follows the document.
|
||||
run(&mut s, "file.properties", json!({"custom": {"Status": null, "Owner": "Ada L."}}));
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
|
||||
assert_eq!(back.custom_prop("Status"), None);
|
||||
assert_eq!(back.custom_prop("Reviewer"), Some("Ada L."));
|
||||
assert_eq!(back.custom_prop("Owner"), Some("Ada L."));
|
||||
assert_eq!(back.custom_props.len(), 2);
|
||||
}
|
||||
|
||||
/// `document_id` tells an edit from a replacement (the UI drops a "Save changes?" prompt
|
||||
/// about a document that has been replaced).
|
||||
#[test]
|
||||
@@ -4454,10 +4647,10 @@ fn document_id_changes_only_when_the_document_is_replaced() {
|
||||
assert_ne!(s.document_id(), second);
|
||||
}
|
||||
|
||||
/// Envelopes, Labels and Finish & Merge make a new, untitled document, as Word does, and like New
|
||||
/// its undo history starts afresh. Undo/Redo across the swap put the wrong document under a file
|
||||
/// (Undo, Save As, Redo, Save wrote the envelope over the saved file), so it isn't offered; the UI
|
||||
/// asks to save the replaced document first.
|
||||
/// Envelopes, Labels and Finish & Merge open a new, untitled document beside the main one, as Word
|
||||
/// does, and like New its undo history starts afresh. Undo/Redo across documents would put the
|
||||
/// wrong document under a file (Undo, Save As, Redo, Save wrote the envelope over the saved file),
|
||||
/// so it isn't offered. The main document stays open as it was, with no undo step for the command.
|
||||
#[test]
|
||||
fn mailings_results_are_new_untitled_documents() {
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-engine-mailings-{}", std::process::id()));
|
||||
@@ -4477,10 +4670,13 @@ fn mailings_results_are_new_untitled_documents() {
|
||||
let before = text(&s);
|
||||
let document = s.document_id();
|
||||
|
||||
let undo_steps = s.undo_labels().len();
|
||||
run(&mut s, id, json!({}));
|
||||
let result = text(&s);
|
||||
assert_ne!(result, before, "{id}: the result replaced the document");
|
||||
assert_ne!(result, before, "{id}: the result is the active document");
|
||||
assert_ne!(s.document_id(), document, "{id}: a different document");
|
||||
let main = s.with_document(document, |s| (text(s), s.undo_labels().len())).unwrap();
|
||||
assert_eq!(main, (before.clone(), undo_steps), "{id}: the main document stays open as it was");
|
||||
assert_eq!(s.path, None, "{id}: the result is untitled");
|
||||
assert_eq!(run(&mut s, "file.save", json!({}))["saved"], false, "{id}: Save asks where (Save As)");
|
||||
assert_eq!(std::fs::read(&original).unwrap(), on_disk, "{id}: the original file is untouched");
|
||||
@@ -4635,6 +4831,29 @@ fn html_pictures_through_a_symlink_out_of_the_folder_are_not_loaded() {
|
||||
assert!(doc.plain_text(StoryRef::Body).contains("Link"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn html_pictures_load_only_from_inside_the_read_root() {
|
||||
// #3736: the HTML file is checked through its link, but its pictures load from beside the
|
||||
// link. Opened through a link outside the read root, they must not come from there.
|
||||
let root = scratch_dir("html-img-root");
|
||||
for d in ["in", "outside"] {
|
||||
std::fs::create_dir_all(root.join(d)).unwrap();
|
||||
}
|
||||
std::fs::write(root.join("in/page.html"), r#"<p><img src="pic.png" alt="Pic"></p>"#).unwrap();
|
||||
std::fs::write(root.join("in/pic.png"), tiny_png()).unwrap();
|
||||
std::fs::write(root.join("outside/pic.png"), tiny_png()).unwrap();
|
||||
std::os::unix::fs::symlink(root.join("in/page.html"), root.join("outside/page.html")).unwrap();
|
||||
let roots = crate::file_access::AutomationRoots::new(Some(&root.join("in")), None).unwrap();
|
||||
let through_link = crate::file_access::confine(roots.as_ref(), || crate::io::open_path(&root.join("outside/page.html")));
|
||||
let direct = crate::file_access::confine(roots.as_ref(), || crate::io::open_path(&root.join("in/page.html")));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let through_link = through_link.unwrap();
|
||||
assert!(through_link.media.is_empty(), "no picture from outside the read root");
|
||||
assert!(through_link.plain_text(StoryRef::Body).contains("Pic"));
|
||||
assert_eq!(direct.unwrap().media.len(), 1, "pictures inside the root still load");
|
||||
}
|
||||
|
||||
/// `file.open` on a path reads only a regular file: a named pipe blocked until a writer came, and
|
||||
/// `/dev/zero` read until memory ran out.
|
||||
#[cfg(unix)]
|
||||
@@ -4660,7 +4879,7 @@ fn opening_a_named_pipe_or_device_is_refused_without_blocking() {
|
||||
let got = rx.recv_timeout(std::time::Duration::from_secs(10));
|
||||
if got.is_err() {
|
||||
// Unblock the stuck reader so the thread ends, then fail.
|
||||
let _ = std::fs::OpenOptions::new().write(true).open(&fifo);
|
||||
let _ = std::os::unix::fs::OpenOptionsExt::custom_flags(std::fs::OpenOptions::new().write(true), libc::O_NONBLOCK).open(&fifo);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let [pipe, device] = got.expect("opening a named pipe blocked");
|
||||
@@ -4689,7 +4908,7 @@ fn html_picture_that_is_a_named_pipe_is_skipped_without_blocking() {
|
||||
let got = rx.recv_timeout(std::time::Duration::from_secs(10));
|
||||
if got.is_err() {
|
||||
// Unblock the stuck reader so the thread ends, then fail.
|
||||
let _ = std::fs::OpenOptions::new().write(true).open(&fifo);
|
||||
let _ = std::os::unix::fs::OpenOptionsExt::custom_flags(std::fs::OpenOptions::new().write(true), libc::O_NONBLOCK).open(&fifo);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let doc = got.expect("opening the HTML file blocked on a named pipe").unwrap();
|
||||
@@ -4719,7 +4938,7 @@ fn insert_picture_from_a_named_pipe_is_refused_without_blocking() {
|
||||
let got = rx.recv_timeout(std::time::Duration::from_secs(10));
|
||||
if got.is_err() {
|
||||
// Unblock the stuck reader so the thread ends, then fail.
|
||||
let _ = std::fs::OpenOptions::new().write(true).open(&fifo);
|
||||
let _ = std::os::unix::fs::OpenOptionsExt::custom_flags(std::fs::OpenOptions::new().write(true), libc::O_NONBLOCK).open(&fifo);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert!(got.expect("reading a picture path blocked on a named pipe").is_err(), "a pipe is not a file to read");
|
||||
@@ -4728,6 +4947,92 @@ fn insert_picture_from_a_named_pipe_is_refused_without_blocking() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #3666: `read_file_capped` (fonts, citation sources, `read_file`) and `read_file_head` (the
|
||||
/// Word 2003 check on saving to an `.xml` path) read only a regular file. A named pipe must be
|
||||
/// refused, not opened: opening one blocks until a writer appears.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn file_head_and_capped_reads_refuse_a_named_pipe_without_blocking() {
|
||||
let dir = scratch_dir("read-helpers-fifo");
|
||||
let fifo = dir.join("pipe.xml");
|
||||
let made = std::process::Command::new("mkfifo").arg(&fifo).status();
|
||||
if !made.is_ok_and(|s| s.success()) {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
eprintln!("mkfifo unavailable; skipping");
|
||||
return;
|
||||
}
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let path = fifo.to_string_lossy().into_owned();
|
||||
std::thread::spawn(move || {
|
||||
let head = crate::io::read_file_head(&path, 64 * 1024).map(|_| ());
|
||||
let capped = crate::io::read_file_capped(&path, 1 << 20, "font file").map(|_| ());
|
||||
let _ = tx.send([head, capped]);
|
||||
});
|
||||
let got = rx.recv_timeout(std::time::Duration::from_secs(10));
|
||||
if got.is_err() {
|
||||
// Unblock the stuck reader (twice: each helper may be stuck in turn) so the thread ends, then
|
||||
// fail. Non-blocking: with no reader left the open fails at once instead of hanging the test.
|
||||
let _ = std::os::unix::fs::OpenOptionsExt::custom_flags(std::fs::OpenOptions::new().write(true), libc::O_NONBLOCK).open(&fifo);
|
||||
let _ = std::os::unix::fs::OpenOptionsExt::custom_flags(std::fs::OpenOptions::new().write(true), libc::O_NONBLOCK).open(&fifo);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let [head, capped] = got.expect("reading a named pipe blocked");
|
||||
assert!(head.unwrap_err().contains("not a file"));
|
||||
assert!(capped.unwrap_err().contains("not a file"));
|
||||
}
|
||||
|
||||
/// #3666: a folder is not a file to read, whichever helper reads it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn file_head_and_capped_reads_refuse_a_folder() {
|
||||
let dir = scratch_dir("read-helpers-dir");
|
||||
let path = dir.to_string_lossy().into_owned();
|
||||
let head = crate::io::read_file_head(&path, 64);
|
||||
let capped = crate::io::read_file_capped(&path, 1 << 20, "font file");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert!(head.unwrap_err().contains("not a file"));
|
||||
assert!(capped.unwrap_err().contains("not a file"));
|
||||
}
|
||||
|
||||
/// #3666: `read_file_capped` reads a file of up to `max` bytes whole and refuses a larger one;
|
||||
/// `read_file_head` returns at most `max` bytes.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn file_capped_read_refuses_content_over_the_cap() {
|
||||
let dir = scratch_dir("read-helpers-cap");
|
||||
let file = dir.join("data.bin");
|
||||
std::fs::write(&file, vec![7u8; 100]).unwrap();
|
||||
let path = file.to_string_lossy().into_owned();
|
||||
let exact = crate::io::read_file_capped(&path, 100, "file");
|
||||
let over = crate::io::read_file_capped(&path, 99, "file");
|
||||
let head = crate::io::read_file_head(&path, 10);
|
||||
let whole = crate::io::read_file_head(&path, 1000);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert_eq!(exact.unwrap().len(), 100);
|
||||
assert!(over.unwrap_err().contains("larger than"));
|
||||
assert_eq!(head.unwrap(), vec![7u8; 10]);
|
||||
assert_eq!(whole.unwrap().len(), 100);
|
||||
}
|
||||
|
||||
/// #3666: `read_regular_file_within` (Paste Special's copied picture file) reads a file of up to
|
||||
/// `limit` bytes whole and refuses one whose handle reports more, before reading any of it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn regular_file_read_within_a_limit_refuses_a_larger_file() {
|
||||
let dir = scratch_dir("read-within");
|
||||
let file = dir.join("data.bin");
|
||||
std::fs::write(&file, vec![7u8; 100]).unwrap();
|
||||
let big = dir.join("big.bin");
|
||||
std::fs::File::create(&big).unwrap().set_len(64 << 20).unwrap();
|
||||
let exact = crate::io::read_regular_file_within(&file, 100);
|
||||
let over = crate::io::read_regular_file_within(&file, 99);
|
||||
let large = crate::io::read_regular_file_within(&big, 1 << 20);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert_eq!(exact.map(|b| b.len()), Some(100));
|
||||
assert_eq!(over, None);
|
||||
assert_eq!(large, None);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn html_picture_size_limit_holds_whatever_length_the_file_reports() {
|
||||
@@ -4815,6 +5120,31 @@ fn styles_apply_pane_is_not_an_edit() {
|
||||
assert!(s.view.styles_pane);
|
||||
}
|
||||
|
||||
/// #1756: the Styles pane docks on the right (the default), on the left, or floats.
|
||||
#[test]
|
||||
fn styles_pane_docks_left_right_or_floats() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Hello"}));
|
||||
s.dirty = false;
|
||||
let undo = s.undo_labels();
|
||||
assert_eq!(run(&mut s, "view.state", json!({}))["stylesPaneDock"], "right");
|
||||
assert_eq!(run(&mut s, "view.stylesPaneDock", json!({})), json!({"dock": "right"}), "no dock reads it");
|
||||
for dock in ["left", "float", "right"] {
|
||||
assert_eq!(run(&mut s, "view.stylesPaneDock", json!({"dock": dock})), json!({"dock": dock}));
|
||||
assert_eq!(run(&mut s, "view.stylesPaneDock", json!({})), json!({"dock": dock}));
|
||||
assert_eq!(run(&mut s, "view.state", json!({}))["stylesPaneDock"], dock);
|
||||
}
|
||||
run(&mut s, "view.stylesPaneDock", json!({"dock": "left"}));
|
||||
for junk in [json!({"dock": "top"}), json!({"dock": 1}), json!({"dock": null}), json!({"dock": ["left"]}), json!({"dock": "LEFT "})] {
|
||||
assert!(s.run("view.stylesPaneDock", &junk).is_err(), "{junk} is an error");
|
||||
assert_eq!(run(&mut s, "view.stylesPaneDock", json!({})), json!({"dock": "left"}), "{junk} changes nothing");
|
||||
}
|
||||
// A view setting: no undo step, the document isn't changed, and the pane's visibility stays.
|
||||
assert_eq!(s.undo_labels(), undo);
|
||||
assert!(!s.dirty);
|
||||
assert!(!s.view.styles_pane);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styles_apply_rejects_bad_style_params() {
|
||||
let mut s = s();
|
||||
@@ -5547,6 +5877,8 @@ fn one_page_is_restored_when_the_document_is_reopened() {
|
||||
// A percentage is restored as a percentage, the fit mode dropped.
|
||||
run(&mut other, "view.zoom", json!({"value": 80}));
|
||||
run(&mut other, "file.save", json!({}));
|
||||
// Opening an open file only shows it: close it first to read it again.
|
||||
run(&mut s, "file.closeDocument", json!({}));
|
||||
run(&mut s, "file.open", json!({"path": path}));
|
||||
assert!(s.view.fit.is_empty() && !s.view.single_column);
|
||||
assert!((s.view.zoom - 0.8).abs() < 1e-6);
|
||||
@@ -9530,9 +9862,10 @@ fn a_rejected_comparison_keeps_the_current_versions() {
|
||||
assert_eq!(s.file_name.as_deref(), Some("original.docx"));
|
||||
}
|
||||
|
||||
/// A rejected mailing result must keep the source document's save target and versions.
|
||||
/// A mailing result opens beside a protected source document, which keeps its save target,
|
||||
/// versions, password and text.
|
||||
#[test]
|
||||
fn a_rejected_mailing_result_keeps_the_current_file_identity() {
|
||||
fn a_mailing_result_beside_a_protected_document_keeps_its_file_identity() {
|
||||
use wordcraft_doc::{CharProps, InlineObject};
|
||||
|
||||
let mut s = Session::new(wordcraft_doc::Document::from_text("Protected source."));
|
||||
@@ -9550,7 +9883,9 @@ fn a_rejected_mailing_result_keeps_the_current_file_identity() {
|
||||
s.password = Some(crate::io::Password::new("synthetic file password"));
|
||||
s.bib_style = "Chicago".into();
|
||||
|
||||
assert!(s.run("mailings.envelopes", &json!({"delivery": "Recipient"})).is_err());
|
||||
s.run("mailings.envelopes", &json!({"delivery": "Recipient"})).unwrap();
|
||||
assert_ne!(s.document_id(), before_id, "the envelope is a new document");
|
||||
s.activate(before_id).unwrap();
|
||||
assert_eq!(s.doc, before);
|
||||
assert_eq!(s.versions.len(), 1);
|
||||
assert_eq!(s.path.as_deref(), Some(std::path::Path::new("source.docx")));
|
||||
@@ -10511,3 +10846,223 @@ fn export_png_refuses_a_path_that_is_not_png() {
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The body's IME replacement epoch advances when the whole body is swapped (setText, Undo/Redo)
|
||||
/// and stays put for ordinary edits; an equation editing session cannot ride across a replacement.
|
||||
#[test]
|
||||
fn ime_body_epoch_marks_body_replacements_not_edits() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "one"}));
|
||||
let first = s.ime_body_epoch();
|
||||
run(&mut s, "text.insert", json!({"text": " two"}));
|
||||
assert_eq!(s.ime_body_epoch(), first, "typing is not a replacement");
|
||||
|
||||
run(&mut s, "document.setText", json!({"text": "same text"}));
|
||||
let replaced = s.ime_body_epoch();
|
||||
assert!(replaced > first, "setText replaces the body under the same document id");
|
||||
assert_eq!(s.document_id(), 1, "the document id itself is unchanged");
|
||||
|
||||
// The same text again still replaces the body: a composition must not survive on content
|
||||
// equality.
|
||||
run(&mut s, "document.setText", json!({"text": "same text"}));
|
||||
assert!(s.ime_body_epoch() > replaced);
|
||||
|
||||
// Undo swaps the body back.
|
||||
let before_undo = s.ime_body_epoch();
|
||||
assert!(s.undo());
|
||||
assert!(s.ime_body_epoch() > before_undo, "Undo replaces the body");
|
||||
}
|
||||
|
||||
/// Replacing the document body while an equation is being edited ends that equation editing
|
||||
/// session.
|
||||
#[test]
|
||||
fn a_body_replacement_ends_equation_editing() {
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.equation", json!({"linear": "x+y"}));
|
||||
assert!(s.math.is_some());
|
||||
run(&mut s, "document.setText", json!({"text": "x+y"}));
|
||||
assert!(s.math.is_none(), "the old equation's editing session is gone");
|
||||
}
|
||||
|
||||
/// A failed command that cleared the equation session puts it back with the document.
|
||||
#[test]
|
||||
fn a_failed_replacement_keeps_equation_editing() {
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.equation", json!({"linear": "x+y"}));
|
||||
let session = s.math.as_ref().map(|m| m.session);
|
||||
assert!(session.is_some());
|
||||
// Missing `text` is rejected before any mutation.
|
||||
assert!(s.run("document.setText", &json!({})).is_err());
|
||||
assert_eq!(s.math.as_ref().map(|m| m.session), session);
|
||||
}
|
||||
|
||||
/// Track Changes: deleting a range whose text is already marked deleted is a real no-op. It
|
||||
/// creates no revision record, no undo-visible change and no IME revision.
|
||||
#[test]
|
||||
fn deleting_an_already_deleted_tracked_range_is_a_classified_noop() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "abc"}));
|
||||
run(&mut s, "review.trackChanges", json!({"value": true}));
|
||||
s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) };
|
||||
run(&mut s, "text.backspace", json!({}));
|
||||
let revisions = s.doc.revisions.len();
|
||||
let revision = s.ime_state_revision().expect("an IME revision");
|
||||
s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) };
|
||||
run(&mut s, "text.backspace", json!({}));
|
||||
let outcome = s.ime_edit_outcome();
|
||||
assert!(outcome.classified, "{outcome:?}");
|
||||
assert!(!outcome.content_changed && !outcome.state_changed, "{outcome:?}");
|
||||
assert_eq!(s.doc.revisions.len(), revisions, "no unused Delete revision is appended");
|
||||
assert_eq!(s.ime_state_revision(), Some(revision), "the no-op did not move the settled caret");
|
||||
s.sel = crate::Selection { anchor: Pos::body(0, 0), focus: Pos::body(0, 1) };
|
||||
assert!(s.ime_state_revision() > Some(revision), "re-selecting moves the caret, not the document");
|
||||
}
|
||||
|
||||
/// `text.insert` classifies the automatic rewrites its own handler performs: smart quotes,
|
||||
/// list autoformat and AutoCorrect are `transformed`, not the IME's literal insert.
|
||||
#[test]
|
||||
fn text_insert_marks_automatic_rewrites_as_transformed() {
|
||||
let mut session = s();
|
||||
run(&mut session, "text.insert", json!({"text": "\""}));
|
||||
assert!(session.ime_edit_outcome().transformed, "smart quote: {:?}", session.ime_edit_outcome());
|
||||
assert_eq!(text(&session), "\u{201C}");
|
||||
|
||||
let mut session = s();
|
||||
for c in ["W", "o", "r", "d", " ", "(", "c", ")", " "] {
|
||||
run(&mut session, "text.insert", json!({"text": c}));
|
||||
}
|
||||
assert!(session.ime_edit_outcome().transformed, "AutoCorrect: {:?}", session.ime_edit_outcome());
|
||||
|
||||
let mut session = s();
|
||||
run(&mut session, "text.insert", json!({"text": "*"}));
|
||||
run(&mut session, "text.insert", json!({"text": " "}));
|
||||
assert!(session.ime_edit_outcome().transformed, "list AutoFormat: {:?}", session.ime_edit_outcome());
|
||||
|
||||
let mut session = s();
|
||||
run(&mut session, "text.insert", json!({"text": "A--b"}));
|
||||
run(&mut session, "text.insert", json!({"text": " "}));
|
||||
assert!(session.ime_edit_outcome().transformed, "dash AutoFormat: {:?}", session.ime_edit_outcome());
|
||||
assert_eq!(text(&session), "A\u{2014}b ");
|
||||
|
||||
let mut session = s();
|
||||
run(&mut session, "tools.autocorrect", json!({"doubleSpacePeriod": true}));
|
||||
for c in "Hello ".chars() {
|
||||
run(&mut session, "text.insert", json!({"text": c.to_string()}));
|
||||
}
|
||||
let outcome = session.ime_edit_outcome();
|
||||
assert!(outcome.transformed && outcome.content_changed && outcome.state_changed, "double-space period: {outcome:?}");
|
||||
assert_eq!(text(&session), "Hello. ");
|
||||
run(&mut session, "edit.undo", json!({}));
|
||||
assert_eq!(text(&session), "Hello ", "undo restores the spaces in one step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn style_set_keeps_imported_styles_latent_styles_and_defaults() {
|
||||
use wordcraft_doc::props::{Rgb, TextColor};
|
||||
use wordcraft_doc::styles::{Style, StyleKind};
|
||||
for name in ["basic", "lines", "shaded", "casual", "centered", "minimalist", "title"] {
|
||||
let mut s = s();
|
||||
let mut body = s.doc.styles.get("Normal").unwrap().clone();
|
||||
body.id = "BodyText".into();
|
||||
body.name = "Body Text".into();
|
||||
body.builtin = true;
|
||||
body.based_on = Some("Normal".into());
|
||||
body.chr.size = Some(20.0);
|
||||
body.chr.color = Some(TextColor::Rgb(Rgb(0xFF, 0, 0)));
|
||||
let grid = Style { id: "GridTable4".into(), name: "Grid Table 4".into(), kind: StyleKind::Table, builtin: true, ..Default::default() };
|
||||
s.doc.styles.styles.push(body.clone());
|
||||
s.doc.styles.styles.push(grid.clone());
|
||||
s.doc.styles.latent_styles = Some("<w:latentStyles/>".into());
|
||||
s.doc.styles.default_chr.lang = Some("de-DE".into());
|
||||
s.doc.styles.default_chr.size = Some(11.0);
|
||||
run(&mut s, "design.styleSet", json!({ "name": name }));
|
||||
assert_eq!(s.doc.styles.get("BodyText"), Some(&body), "{name}: BodyText");
|
||||
assert_eq!(s.doc.styles.get("GridTable4"), Some(&grid), "{name}: GridTable4");
|
||||
assert_eq!(s.doc.styles.latent_styles.as_deref(), Some("<w:latentStyles/>"), "{name}");
|
||||
assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("de-DE"), "{name}");
|
||||
assert_eq!(s.doc.styles.default_chr.size, Some(11.0), "{name}");
|
||||
assert_eq!(s.doc.styles.styles.iter().filter(|x| x.id == "Heading1").count(), 1, "{name}");
|
||||
let saved = crate::io::save_bytes("x.docx", &s.doc).unwrap();
|
||||
let back = crate::io::open_bytes("x.docx", &saved).unwrap();
|
||||
assert!(back.styles.get("BodyText").is_some(), "{name}: BodyText after round trip");
|
||||
}
|
||||
let mut s = s();
|
||||
run(&mut s, "design.styleSet", json!({ "name": "title" }));
|
||||
assert_eq!(s.doc.styles.get("Title").unwrap().chr.size, Some(36.0));
|
||||
}
|
||||
|
||||
fn style_size(s: &Session, id: &str) -> f32 {
|
||||
s.doc.styles.resolve_char(Some(id), &Default::default()).size
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_font_sizes_adds_points_to_every_style() {
|
||||
let mut s = Session::new(crate::sample::sample_document());
|
||||
let ids: Vec<String> = s.doc.styles.styles.iter().map(|x| x.id.clone()).collect();
|
||||
let before: Vec<f32> = ids.iter().map(|i| style_size(&s, i)).collect();
|
||||
let base = s.doc.styles.resolve_char(None, &Default::default()).size;
|
||||
let runs = serde_json::to_string(&s.doc.body).unwrap();
|
||||
run(&mut s, "design.scaleFontSizes", json!({"delta": 2}));
|
||||
// Every style (explicit or inherited) grows by exactly 2, once.
|
||||
for (i, b) in ids.iter().zip(&before) {
|
||||
assert_eq!(style_size(&s, i), b + 2.0, "{i}");
|
||||
}
|
||||
assert_eq!(s.doc.styles.resolve_char(None, &Default::default()).size, base + 2.0);
|
||||
assert_eq!(serde_json::to_string(&s.doc.body).unwrap(), runs, "runs untouched");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
for (i, b) in ids.iter().zip(&before) {
|
||||
assert_eq!(style_size(&s, i), *b, "undo {i}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_font_sizes_percent_and_clamp_and_errors() {
|
||||
let mut s = Session::new(crate::sample::sample_document());
|
||||
let n = style_size(&s, "Normal");
|
||||
run(&mut s, "design.scaleFontSizes", json!({"percent": 10}));
|
||||
assert_eq!(style_size(&s, "Normal"), (n * 1.1 * 2.0).round() / 2.0);
|
||||
run(&mut s, "design.scaleFontSizes", json!({"delta": -500}));
|
||||
assert_eq!(style_size(&s, "Normal"), 1.0);
|
||||
for bad in [
|
||||
json!({}),
|
||||
json!({"delta": 1e9}),
|
||||
json!({"percent": -100}),
|
||||
json!({"percent": 1e9}),
|
||||
json!({"delta": "x"}),
|
||||
json!({"delta": 0}),
|
||||
json!({"percent": 0}),
|
||||
json!({"delta": 1, "percent": 10}),
|
||||
json!({"delta": 2, "percent": "x"}),
|
||||
json!({"percent": 10, "delta": null}),
|
||||
] {
|
||||
assert!(s.run("design.scaleFontSizes", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_font_sizes_reaches_char_table_and_cs_sizes_and_unset_default() {
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::{Style, StyleKind, styles::TablePart};
|
||||
let mut s = s();
|
||||
s.doc.styles.default_chr.size = None;
|
||||
s.doc.styles.default_chr.size_cs = Some(10.0);
|
||||
let mut ch = Style { id: "Big".into(), name: "Big".into(), kind: StyleKind::Character, ..Default::default() };
|
||||
ch.chr.size = Some(20.0);
|
||||
ch.chr.size_cs = Some(22.0);
|
||||
let mut tb = Style { id: "Grid".into(), name: "Grid".into(), kind: StyleKind::Table, ..Default::default() };
|
||||
let mut parts = wordcraft_doc::styles::TableStyleParts::default();
|
||||
parts.header_chr.size = Some(14.0);
|
||||
parts.regions.entry(TablePart::Band2Horz).or_default().chr.size = Some(8.0);
|
||||
tb.table = Some(parts);
|
||||
s.doc.styles.styles.extend([ch, tb]);
|
||||
run(&mut s, "design.scaleFontSizes", json!({"delta": 2}));
|
||||
let st = &s.doc.styles;
|
||||
assert_eq!(st.default_chr.size, Some(13.0), "unset default is 11 pt, made explicit");
|
||||
assert_eq!(st.default_chr.size_cs, Some(12.0));
|
||||
let big = CharProps { style: Some("Big".into()), ..Default::default() };
|
||||
assert_eq!(st.resolve_char(None, &big).size, 22.0);
|
||||
assert_eq!(st.resolve_char(None, &big).size_cs, 24.0);
|
||||
let grid = st.styles.iter().find(|x| x.id == "Grid").and_then(|x| x.table.as_ref()).unwrap();
|
||||
assert_eq!(grid.header_chr.size, Some(16.0));
|
||||
assert_eq!(grid.regions.get(&TablePart::Band2Horz).unwrap().chr.size, Some(10.0));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
//! Several open documents in one session: File › New and Open open beside the others, each
|
||||
//! document keeps its own text, selection, undo and find state, and work addressed to a document
|
||||
//! by id lands in that document whichever one is active.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::StoryRef;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn s() -> Session {
|
||||
Session::new(wordcraft_doc::Document::new())
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, v: Value) -> Value {
|
||||
s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}"))
|
||||
}
|
||||
|
||||
fn text(s: &Session) -> String {
|
||||
s.doc.plain_text(StoryRef::Body)
|
||||
}
|
||||
|
||||
fn ids(s: &Session) -> Vec<u64> {
|
||||
s.documents().iter().map(|d| d.id).collect()
|
||||
}
|
||||
|
||||
fn temp_dir(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-engine-docs-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_opens_another_document_and_each_keeps_its_own_text_and_undo() {
|
||||
let mut s = s();
|
||||
let a = s.document_id();
|
||||
run(&mut s, "text.insert", json!({"text": "Alpha"}));
|
||||
run(&mut s, "file.new", json!({}));
|
||||
let b = s.document_id();
|
||||
assert_ne!(a, b);
|
||||
assert_eq!(ids(&s), vec![a, b], "opening order");
|
||||
let docs = s.documents();
|
||||
assert_eq!(docs.iter().map(|d| (d.title.as_str(), d.active)).collect::<Vec<_>>(), vec![("Document1", false), ("Document2", true)]);
|
||||
assert!(docs[0].dirty && !docs[1].dirty);
|
||||
assert_eq!(text(&s), "", "the new document is active");
|
||||
run(&mut s, "text.insert", json!({"text": "Beta"}));
|
||||
|
||||
s.activate(a).unwrap();
|
||||
assert_eq!(text(&s), "Alpha");
|
||||
assert_eq!(s.document_id(), a, "ids stay with their documents");
|
||||
s.activate(a).unwrap();
|
||||
s.activate(b).unwrap();
|
||||
assert_eq!(text(&s), "Beta");
|
||||
run(&mut s, "file.new", json!({}));
|
||||
assert_eq!(s.documents()[2].title, "Document3");
|
||||
}
|
||||
|
||||
/// Undo goes with its document: edit A, edit B, back to A, Undo takes A's edit back and leaves
|
||||
/// B alone; Redo puts it back. Find state stays with its document too.
|
||||
#[test]
|
||||
fn undo_and_find_stay_with_their_document_across_switches() {
|
||||
let mut s = s();
|
||||
let a = s.document_id();
|
||||
run(&mut s, "text.insert", json!({"text": "Alpha"}));
|
||||
s.find.query = "Alpha".into();
|
||||
let b = s.add_document(wordcraft_doc::Document::new());
|
||||
assert_eq!(s.find.query, "", "a new document has its own find state");
|
||||
run(&mut s, "text.insert", json!({"text": "Beta"}));
|
||||
s.find.query = "Beta".into();
|
||||
|
||||
s.activate(a).unwrap();
|
||||
assert_eq!(s.find.query, "Alpha");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(text(&s), "");
|
||||
assert_eq!(s.with_document(b, |s| (text(s), s.undo_depth(), s.find.query.clone())).unwrap(), ("Beta".to_string(), 1, "Beta".to_string()));
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(text(&s), "Alpha");
|
||||
s.activate(b).unwrap();
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(text(&s), "");
|
||||
assert_eq!(s.with_document(a, |s| text(s)).unwrap(), "Alpha");
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn open_replaces_an_untouched_blank_document_and_otherwise_opens_beside() {
|
||||
let dir = temp_dir("open");
|
||||
let one = dir.join("one.txt");
|
||||
let two = dir.join("two.txt");
|
||||
std::fs::write(&one, "One").unwrap();
|
||||
std::fs::write(&two, "Two").unwrap();
|
||||
|
||||
let mut s = s();
|
||||
assert!(s.is_untouched_blank());
|
||||
let r = run(&mut s, "file.open", json!({"path": one.to_string_lossy()}));
|
||||
assert_eq!(r["alreadyOpen"], false);
|
||||
assert_eq!(s.documents().len(), 1, "the untouched blank document was replaced");
|
||||
let first = s.document_id();
|
||||
assert_eq!(s.documents()[0].title, "one");
|
||||
|
||||
run(&mut s, "file.open", json!({"path": two.to_string_lossy()}));
|
||||
assert_eq!(s.documents().len(), 2, "a document with a file stays open");
|
||||
let second = s.document_id();
|
||||
assert_eq!(text(&s), "Two");
|
||||
|
||||
// The same file again, by another spelling: it becomes the active document.
|
||||
let again = dir.join(".").join("one.txt");
|
||||
let r = run(&mut s, "file.open", json!({"path": again.to_string_lossy()}));
|
||||
assert_eq!((r["alreadyOpen"].as_bool(), r["id"].as_u64()), (Some(true), Some(first)));
|
||||
assert_eq!((s.document_id(), s.documents().len()), (first, 2));
|
||||
assert_eq!(s.find_open(&two), Some(second));
|
||||
|
||||
// A failed open changes nothing.
|
||||
assert!(s.run("file.open", &json!({"path": dir.join("missing.docx").to_string_lossy()})).is_err());
|
||||
assert_eq!((ids(&s), s.document_id()), (vec![first, second], first));
|
||||
|
||||
// An edited blank document isn't replaced.
|
||||
let mut s = self::s();
|
||||
run(&mut s, "text.insert", json!({"text": "x"}));
|
||||
run(&mut s, "text.backspace", json!({}));
|
||||
assert!(!s.is_untouched_blank());
|
||||
run(&mut s, "file.open", json!({"path": one.to_string_lossy()}));
|
||||
assert_eq!(s.documents().len(), 2);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_activates_the_previous_document_and_the_last_leaves_a_blank_one() {
|
||||
let mut s = s();
|
||||
let a = s.document_id();
|
||||
let b = s.add_document(wordcraft_doc::Document::from_text("B"));
|
||||
let c = s.add_document(wordcraft_doc::Document::from_text("C"));
|
||||
s.activate(b).unwrap();
|
||||
s.close_document(b).unwrap();
|
||||
assert_eq!((ids(&s), s.document_id()), (vec![a, c], a), "the one opened before it");
|
||||
s.close_document(c).unwrap();
|
||||
s.close_document(a).unwrap();
|
||||
assert_eq!(s.documents().len(), 1);
|
||||
assert!(![a, b, c].contains(&s.document_id()));
|
||||
assert!(s.is_untouched_blank());
|
||||
assert!(s.close_document(a).is_err(), "closed documents are gone");
|
||||
|
||||
// Closing the first makes the next one active.
|
||||
let d = s.document_id();
|
||||
let e = s.add_document(wordcraft_doc::Document::new());
|
||||
s.activate(d).unwrap();
|
||||
s.close_document(d).unwrap();
|
||||
assert_eq!(s.document_id(), e);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_document_refuses_unsaved_changes_unless_forced() {
|
||||
let mut s = s();
|
||||
let a = s.document_id();
|
||||
run(&mut s, "text.insert", json!({"text": "Draft"}));
|
||||
let b = s.add_document(wordcraft_doc::Document::new());
|
||||
assert!(s.run("file.closeDocument", &json!({"id": a})).is_err());
|
||||
assert_eq!(ids(&s), vec![a, b], "still open");
|
||||
let r = run(&mut s, "file.closeDocument", json!({}));
|
||||
assert_eq!((r["closed"].as_u64(), r["active"].as_u64()), (Some(b), Some(a)));
|
||||
let r = run(&mut s, "file.closeDocument", json!({"id": a, "force": true}));
|
||||
assert_eq!(r["documents"].as_array().map(Vec::len), Some(1));
|
||||
assert!(s.is_untouched_blank());
|
||||
for bad in [json!({"id": -1}), json!({"id": "x"}), json!({"id": u64::MAX}), json!({"id": 1.5}), json!({"id": []})] {
|
||||
assert!(s.run("file.closeDocument", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.documents().len(), 1);
|
||||
}
|
||||
|
||||
/// Work addressed to a document by id lands in it, whichever document is active, and the active
|
||||
/// one is left as it was.
|
||||
#[test]
|
||||
fn with_document_works_on_the_given_document_and_keeps_the_active_one() {
|
||||
let mut s = s();
|
||||
let a = s.document_id();
|
||||
run(&mut s, "text.insert", json!({"text": "Alpha"}));
|
||||
let b = s.add_document(wordcraft_doc::Document::new());
|
||||
run(&mut s, "text.insert", json!({"text": "Beta"}));
|
||||
let (b_rev, b_undo) = (s.rev(), s.undo_depth());
|
||||
|
||||
let r = s.with_document(a, |s| {
|
||||
s.run("text.insert", &json!({"text": " more"})).unwrap();
|
||||
s.run("edit.advancedFind", &json!({})).ok();
|
||||
s.document_id()
|
||||
});
|
||||
assert_eq!(r.unwrap(), a);
|
||||
assert_eq!(s.document_id(), b);
|
||||
assert_eq!((text(&s), s.rev(), s.undo_depth()), ("Beta".to_string(), b_rev, b_undo), "B untouched");
|
||||
assert_eq!(s.with_document(a, |s| text(s)).unwrap(), "Alpha more");
|
||||
assert!(s.ui_requests.iter().all(|q| q.get("open").is_none() || q["document"] == a), "requests name their document: {:?}", s.ui_requests);
|
||||
assert!(s.with_document(u64::MAX, |_| ()).is_err());
|
||||
|
||||
// Closing the active document inside leaves the one it ran on active.
|
||||
s.with_document(a, |s| s.close_document(b)).unwrap().unwrap();
|
||||
assert_eq!(ids(&s), vec![a]);
|
||||
assert_eq!(s.document_id(), a);
|
||||
}
|
||||
|
||||
/// A Read Aloud reading names its document: switching documents stops it, and turning citation
|
||||
/// skipping on in another document doesn't restart it at the first document's positions there.
|
||||
#[test]
|
||||
fn read_aloud_belongs_to_its_document() {
|
||||
let mut s = s();
|
||||
let a = s.document_id();
|
||||
run(&mut s, "text.insert", json!({"text": "One sentence. Another one."}));
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
run(&mut s, "review.readAloud", json!({}));
|
||||
assert_eq!(s.read_aloud.document, Some(a));
|
||||
let b = s.add_document(wordcraft_doc::Document::from_text("Other text."));
|
||||
assert_eq!(s.read_aloud.state(), crate::speech::State::Stopped, "the reading of A stopped");
|
||||
run(&mut s, "readAloud.skipCitations", json!({"value": false}));
|
||||
assert_eq!(s.read_aloud.document, Some(a), "nothing restarted in B");
|
||||
run(&mut s, "review.readAloud", json!({}));
|
||||
assert_eq!(s.read_aloud.document, Some(b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_windows_lists_and_activates() {
|
||||
let mut s = s();
|
||||
let a = s.document_id();
|
||||
let b = s.add_document(wordcraft_doc::Document::new());
|
||||
let r = run(&mut s, "view.switchWindows", json!({}));
|
||||
let list = r["documents"].as_array().unwrap();
|
||||
assert_eq!(
|
||||
list.iter().map(|d| (d["id"].as_u64(), d["title"].as_str(), d["active"].as_bool())).collect::<Vec<_>>(),
|
||||
vec![(Some(a), Some("Document1"), Some(false)), (Some(b), Some("Document2"), Some(true))]
|
||||
);
|
||||
assert!(list[0].get("path").is_some() && list[0].get("dirty").is_some());
|
||||
run(&mut s, "view.switchWindows", json!({"id": a}));
|
||||
assert_eq!(s.document_id(), a);
|
||||
for bad in [json!({"id": 0}), json!({"id": u64::MAX}), json!({"id": -5}), json!({"id": "1"}), json!({"id": 1e300}), json!({"id": {}})] {
|
||||
assert!(s.run("view.switchWindows", &bad).is_err(), "{bad}");
|
||||
assert_eq!(s.document_id(), a);
|
||||
}
|
||||
let junk = [json!(null), json!([]), json!({"id": null}), json!({"text": 5})];
|
||||
for id in ["view.switchWindows", "file.closeDocument", "file.new"] {
|
||||
for j in &junk {
|
||||
let _ = s.run(id, j);
|
||||
s.clamp_selection();
|
||||
let _ = s.layout();
|
||||
}
|
||||
}
|
||||
assert!(!s.documents().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_ids_are_unique_across_sessions_and_replacements() {
|
||||
let mut one = s();
|
||||
let two = s();
|
||||
assert_ne!(one.document_id(), two.document_id());
|
||||
let before = one.document_id();
|
||||
one.set_document(wordcraft_doc::Document::new());
|
||||
assert_ne!(one.document_id(), before);
|
||||
assert_eq!(ids(&one), vec![one.document_id()], "the replacement takes its place in the list");
|
||||
}
|
||||
|
||||
/// A Start screen's stand-in document gives back its name: the first document a person makes is
|
||||
/// `Document1`, the next `Document2`, and after closing the last one, New makes `Document2`
|
||||
/// again only if no other document has taken it.
|
||||
#[test]
|
||||
fn a_stand_in_document_gives_back_its_untitled_name() {
|
||||
let mut s = s();
|
||||
let placeholder = s.document_id();
|
||||
s.release_untitled_name();
|
||||
run(&mut s, "file.new", json!({}));
|
||||
s.close_document(placeholder).unwrap();
|
||||
assert_eq!(s.documents()[0].title, "Document1");
|
||||
run(&mut s, "file.new", json!({}));
|
||||
assert_eq!(s.documents()[1].title, "Document2");
|
||||
// A document with work keeps its name.
|
||||
run(&mut s, "text.insert", json!({"text": "Work"}));
|
||||
s.release_untitled_name();
|
||||
run(&mut s, "file.new", json!({}));
|
||||
assert_eq!(s.documents()[2].title, "Document3");
|
||||
}
|
||||
|
||||
/// Two windows on one document: each keeps its own caret and zoom; a caret inside text another
|
||||
/// window deleted is clamped, so typing there lands at the end of what is left; Undo in either
|
||||
/// window undoes the document's last edit, and typing in one window never joins the other's
|
||||
/// undo step.
|
||||
#[test]
|
||||
fn two_views_of_one_document_keep_their_own_caret_and_share_undo() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Hello world"}));
|
||||
let mut b = s.copy_view();
|
||||
let a_caret = s.sel.focus.clone();
|
||||
|
||||
// Window B: zoom in; its caret is after "world".
|
||||
let mut a = s.take_view();
|
||||
s.put_view(b);
|
||||
run(&mut s, "view.zoom", json!({"value": 150}));
|
||||
b = s.take_view();
|
||||
s.put_view(a);
|
||||
assert_eq!(s.view.zoom, 1.0, "window A keeps its zoom");
|
||||
|
||||
// Window A deletes " world", where B's caret is.
|
||||
run(&mut s, "select.text", json!({"text": " world"}));
|
||||
run(&mut s, "text.delete", json!({}));
|
||||
assert_eq!(text(&s).trim(), "Hello");
|
||||
a = s.take_view();
|
||||
s.put_view(b);
|
||||
assert!((s.view.zoom - 1.5).abs() < 1e-6, "window B keeps its zoom");
|
||||
assert_eq!(s.sel.focus.off, 5, "B's caret is clamped into what is left");
|
||||
run(&mut s, "text.insert", json!({"text": "!"}));
|
||||
assert_eq!(text(&s).trim(), "Hello!");
|
||||
|
||||
// Undo in A undoes B's typing; Undo in B then undoes A's delete.
|
||||
b = s.take_view();
|
||||
s.put_view(a);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(text(&s).trim(), "Hello");
|
||||
a = s.take_view();
|
||||
s.put_view(b);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(text(&s).trim(), "Hello world");
|
||||
|
||||
// Typing in A, then B, then A again: three undo steps.
|
||||
b = s.take_view();
|
||||
s.put_view(a);
|
||||
s.sel = crate::Selection::caret(a_caret);
|
||||
run(&mut s, "text.insert", json!({"text": "x"}));
|
||||
a = s.take_view();
|
||||
s.put_view(b);
|
||||
run(&mut s, "text.insert", json!({"text": "y"}));
|
||||
b = s.take_view();
|
||||
s.put_view(a);
|
||||
run(&mut s, "text.insert", json!({"text": "z"}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(!text(&s).contains('z') && text(&s).contains('x') && text(&s).contains('y'), "{}", text(&s));
|
||||
drop(b);
|
||||
}
|
||||
|
||||
/// An input method's promise that the next insertion joins its replacement belongs to the
|
||||
/// window that made it: another window on the document typing at the same place gets an undo
|
||||
/// step of its own.
|
||||
#[test]
|
||||
fn input_method_joins_stay_with_their_window() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "Hello"}));
|
||||
s.join_next_ime_text_replacement();
|
||||
let b = s.copy_view();
|
||||
let a = s.take_view();
|
||||
s.put_view(b);
|
||||
run(&mut s, "text.insert", json!({"text": "y"}));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(text(&s).trim(), "Hello", "B's typing is its own undo step");
|
||||
drop(a);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Regressions where main's "a command that changes nothing is not an edit" (#23) meets the
|
||||
//! migration's input-method application-change and state-revision tracking.
|
||||
//!
|
||||
//! Kept in its own module so the merged `tests.rs` is not touched.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Creates an empty-document session for the IME no-op regressions.
|
||||
fn s() -> Session {
|
||||
Session::new(wordcraft_doc::Document::new())
|
||||
}
|
||||
|
||||
/// Runs a command in the test session, panicking with the command id on error.
|
||||
fn run(s: &mut Session, id: &str, v: serde_json::Value) -> serde_json::Value {
|
||||
s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}"))
|
||||
}
|
||||
|
||||
/// An *unclassified* no-op (`text.backspace` at the very start of the body) is not an edit and
|
||||
/// must not look like one to the input method: no effect flags, no application-change signal, no
|
||||
/// settled-revision move, and no history/dirty/revision side effects.
|
||||
#[test]
|
||||
fn a_no_op_command_is_not_an_ime_state_change() {
|
||||
let mut s = s();
|
||||
run(&mut s, "text.insert", json!({"text": "x"}));
|
||||
run(&mut s, "caret.docStart", json!({}));
|
||||
assert!(s.pending.is_none(), "caret movement dropped any pending formatting");
|
||||
s.take_pending_ime_application_change();
|
||||
let revision = s.ime_state_revision().expect("an IME revision");
|
||||
let rev = s.rev();
|
||||
let depth = s.undo_depth();
|
||||
let dirty = s.dirty;
|
||||
|
||||
run(&mut s, "text.backspace", json!({}));
|
||||
|
||||
let outcome = s.ime_edit_outcome();
|
||||
assert!(!outcome.classified, "the handler leaves this no-op unclassified: {outcome:?}");
|
||||
assert!(!outcome.content_changed && !outcome.state_changed, "{outcome:?}");
|
||||
assert!(!s.take_pending_ime_application_change(), "a no-op is not an application change");
|
||||
assert_eq!(s.ime_state_revision(), Some(revision), "the settled IME revision did not move");
|
||||
assert_eq!((s.undo_depth(), s.dirty, s.rev()), (depth, dirty, rev));
|
||||
}
|
||||
|
||||
/// `document.setText` with identical text replaces the body under the same document id, so the
|
||||
/// IME replacement epoch must advance — but because nothing observable changed it is not an edit
|
||||
/// and must not signal an application-origin change (main's #23 no-op rule).
|
||||
#[test]
|
||||
fn a_same_text_set_text_advances_the_body_epoch_without_an_application_change() {
|
||||
let mut s = s();
|
||||
run(&mut s, "document.setText", json!({"text": "Same body"}));
|
||||
s.take_pending_ime_application_change();
|
||||
let epoch = s.ime_body_epoch();
|
||||
let document_id = s.document_id();
|
||||
let rev = s.rev();
|
||||
let depth = s.undo_depth();
|
||||
|
||||
run(&mut s, "document.setText", json!({"text": "Same body"}));
|
||||
|
||||
assert!(s.ime_body_epoch() > epoch, "the body is replaced again under the same document id");
|
||||
assert_eq!(s.document_id(), document_id, "the document id itself is unchanged");
|
||||
assert!(!s.take_pending_ime_application_change(), "an identical setText is not an application change");
|
||||
assert_eq!((s.undo_depth(), s.rev()), (depth, rev));
|
||||
}
|
||||
|
||||
/// The IME body replacement promise (`DeleteSurrounding`, then the commit that replaces the
|
||||
/// erased text) joins only a `text.insert` at the selection the deletion left. Tab, an ordinary
|
||||
/// command, consumes it: it is no IME replacement receipt itself, and the commit after it owns a
|
||||
/// new Undo step instead of joining the deletion.
|
||||
#[test]
|
||||
fn tab_between_an_ime_replacement_and_its_commit_keeps_the_steps_apart() {
|
||||
let text = |s: &Session| s.doc.plain_text(wordcraft_doc::StoryRef::Body);
|
||||
|
||||
// The promise itself joins the commit at the unchanged selection.
|
||||
let mut s = self::s();
|
||||
run(&mut s, "text.insert", json!({"text": "ab"}));
|
||||
run(&mut s, "text.backspace", json!({}));
|
||||
assert_eq!(text(&s), "a", "DeleteSurrounding erased the b");
|
||||
s.join_next_ime_text_replacement();
|
||||
let depth = s.undo_depth();
|
||||
run(&mut s, "text.insert", json!({"text": "c"}));
|
||||
assert_eq!(text(&s), "ac");
|
||||
assert_eq!(s.undo_depth(), depth, "the commit joins the deletion's step");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(text(&s), "ab", "one Undo restores the deletion and its commit");
|
||||
|
||||
// Tab consumes the promise: the commit no longer joins the deletion.
|
||||
let mut s = self::s();
|
||||
run(&mut s, "text.insert", json!({"text": "ab"}));
|
||||
run(&mut s, "text.backspace", json!({}));
|
||||
s.join_next_ime_text_replacement();
|
||||
let depth = s.undo_depth();
|
||||
run(&mut s, "text.tab", json!({}));
|
||||
let outcome = s.ime_edit_outcome();
|
||||
assert!(!outcome.classified, "Tab is not an IME replacement receipt: {outcome:?}");
|
||||
assert_eq!(s.undo_depth(), depth + 1, "Tab owns its Undo step");
|
||||
run(&mut s, "text.insert", json!({"text": "c"}));
|
||||
assert_eq!(text(&s), "a\tc");
|
||||
assert_eq!(s.undo_depth(), depth + 2, "the commit after Tab starts its own Undo step");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(text(&s), "a\t", "Undo takes back only the commit");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(text(&s), "a", "then the Tab");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(text(&s), "ab", "and the IME deletion on its own");
|
||||
|
||||
// Returning directly to the promised selection must not resurrect a promise consumed by
|
||||
// Tab. Do not use a caret command here: that command would itself consume a leaked promise.
|
||||
let mut s = self::s();
|
||||
run(&mut s, "text.insert", json!({"text": "ab"}));
|
||||
run(&mut s, "text.backspace", json!({}));
|
||||
s.join_next_ime_text_replacement();
|
||||
let promised_selection = s.sel.clone();
|
||||
run(&mut s, "text.tab", json!({}));
|
||||
let depth = s.undo_depth();
|
||||
s.sel = promised_selection;
|
||||
run(&mut s, "text.insert", json!({"text": "c"}));
|
||||
assert_eq!(text(&s), "ac\t");
|
||||
assert_eq!(s.undo_depth(), depth + 1, "returning to the old selection cannot reuse the consumed promise");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(text(&s), "a\t", "only the new insert is undone");
|
||||
}
|
||||
@@ -3303,3 +3303,86 @@ fn highlight_partial_column_ignores_unselected_marks() {
|
||||
assert_eq!(p.mark.highlight, Some(Highlight::Yellow));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_with_a_whole_paragraph_selected_indents_it() {
|
||||
let mut s = typed("Hello world");
|
||||
run(&mut s, "select.paragraph", json!({}));
|
||||
run(&mut s, "text.tab", json!({}));
|
||||
assert_eq!(paras(&s), vec![para("Hello world", None)]);
|
||||
assert_eq!(indents(&s).0, Some(36.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_with_a_partial_selection_still_inserts_a_tab() {
|
||||
let mut s = typed("Hello world");
|
||||
run(&mut s, "select.text", json!({"text": "ello"}));
|
||||
run(&mut s, "text.tab", json!({}));
|
||||
assert_eq!(paras(&s), vec![para("H\t world", None)]);
|
||||
}
|
||||
|
||||
/// Tab with a whole paragraph selected indents it as one Undo step: the selection stays while
|
||||
/// the indent is applied, and Undo restores both the indentation and that same selection. A
|
||||
/// partial selection still types a tab character (the test above), and Tab in a table cell still
|
||||
/// navigates (the table tests above).
|
||||
#[test]
|
||||
fn tab_with_a_whole_paragraph_selected_undoes_indent_and_selection_together() {
|
||||
let mut s = typed("Hello world");
|
||||
run(&mut s, "select.paragraph", json!({}));
|
||||
let selected = s.sel.clone();
|
||||
let depth = s.undo_depth();
|
||||
run(&mut s, "text.tab", json!({}));
|
||||
assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello world", "Tab indents, it types no tab");
|
||||
assert_eq!(s.sel, selected, "the whole paragraph stays selected");
|
||||
assert_eq!(indents(&s), (Some(36.0), None));
|
||||
assert_eq!(s.undo_depth(), depth + 1, "one paragraph, one Undo step");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(indents(&s), (None, None), "Undo takes the indent back");
|
||||
assert_eq!(s.sel, selected, "and restores the selection the indent was applied to");
|
||||
assert_eq!(paras(&s), vec![para("Hello world", None)]);
|
||||
}
|
||||
|
||||
/// #2848: "Add a period with double-space" (an AutoFormat As You Type switch, off by default):
|
||||
/// a second Space after a word, a number or a closing bracket or quote turns the first into a
|
||||
/// period. Undo puts the two spaces back; other punctuation, a lone space and a run of three
|
||||
/// spaces are left as typed.
|
||||
#[test]
|
||||
fn double_space_adds_a_period_when_asked() {
|
||||
let with_option = |keys: &str| {
|
||||
let mut s = s();
|
||||
run(&mut s, "tools.autocorrect", json!({"doubleSpacePeriod": true}));
|
||||
typ(&mut s, keys);
|
||||
s
|
||||
};
|
||||
let text = |s: &Session| s.doc.plain_text(StoryRef::Body);
|
||||
// Off unless asked for.
|
||||
assert_eq!(text(&typed("Hello world")), "Hello world");
|
||||
assert!(!s().prefs.autocorrect.double_space_period);
|
||||
// On: the second space makes the period, and the next sentence starts with a capital.
|
||||
assert_eq!(text(&with_option("Hello world")), "Hello. world");
|
||||
assert_eq!(text(&with_option("Hello world ")), "Hello. World ");
|
||||
assert_eq!(text(&with_option("See page 5 Then")), "See page 5. Then");
|
||||
assert_eq!(text(&with_option("(it is done) Next")), "(it is done). Next");
|
||||
assert_eq!(text(&with_option("He said \"stop\" Next")), "He said “stop”. Next");
|
||||
// Undo brings the two spaces back, in one step.
|
||||
let mut t = with_option("Hello ");
|
||||
assert_eq!(text(&t), "Hello. ");
|
||||
run(&mut t, "edit.undo", json!({}));
|
||||
assert_eq!(text(&t), "Hello ");
|
||||
// Left alone: other punctuation before the spaces, a lone space, spaces at the start of a
|
||||
// paragraph or after a tab, and a third space.
|
||||
for keys in ["Hello. x", "Hello, x", "Hello? x", "Hello; x", "Hello: x", "Hello x", " x", "\n x", "a\t x"] {
|
||||
let want = typed(keys);
|
||||
assert_eq!(text(&with_option(keys)), text(&want), "{keys:?}");
|
||||
}
|
||||
assert_eq!(text(&with_option("Hello x")), "Hello. x");
|
||||
// A selection typed over, and the master switch.
|
||||
let mut t = with_option("Hello world");
|
||||
run(&mut t, "select.all", json!({}));
|
||||
run(&mut t, "text.insert", json!({"text": " "}));
|
||||
assert_eq!(text(&t), " ");
|
||||
let mut t = s();
|
||||
run(&mut t, "tools.autocorrect", json!({"doubleSpacePeriod": true, "enabled": false}));
|
||||
typ(&mut t, "Hello world");
|
||||
assert_eq!(text(&t), "Hello world");
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user