mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-10 20:51:50 +00:00
Merge upstream/main into feat/322-view-windows
Main now gives every document its own native window in one process, with Switch Windows and New Window. Drop the one-process-per-window registry and peer channel and build Arrange All, View Side by Side and Synchronous Scrolling on those windows: the drawn window sees the others by window id, tiles and halves are placed through the windows' viewports, and the scrolling is handed to the partner window in document points. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
841dd538dc
171 files changed
+14821
-2916
No files matched your search
Generated
+124
-173
@@ -221,9 +221,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "arboard"
|
||||
version = "3.6.1"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
|
||||
checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917"
|
||||
dependencies = [
|
||||
"clipboard-win",
|
||||
"image",
|
||||
@@ -235,7 +235,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
@@ -489,15 +489,6 @@ version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
||||
dependencies = [
|
||||
"bit-vec 0.8.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.10.0"
|
||||
@@ -508,10 +499,13 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.8.0"
|
||||
name = "bit-set"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c"
|
||||
dependencies = [
|
||||
"bit-vec 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
@@ -519,6 +513,16 @@ version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269"
|
||||
dependencies = [
|
||||
"borsh",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
@@ -580,6 +584,30 @@ dependencies = [
|
||||
"piper",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a"
|
||||
dependencies = [
|
||||
"borsh-derive",
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh-derive"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "8.0.4"
|
||||
@@ -983,10 +1011,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
@@ -1009,7 +1042,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
|
||||
dependencies = [
|
||||
"krilla",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1018,10 +1051,10 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
|
||||
dependencies = [
|
||||
"craft-winprint-gdi",
|
||||
"miniz_oxide 0.8.9",
|
||||
"miniz_oxide 0.9.1",
|
||||
"roxmltree",
|
||||
"tempfile",
|
||||
"winprint",
|
||||
@@ -1031,15 +1064,27 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
"egui",
|
||||
"epaint",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"skrifa 0.44.0",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -1061,6 +1106,17 @@ dependencies = [
|
||||
"zip 9.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"wayland-client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-winprint-gdi"
|
||||
version = "0.1.0"
|
||||
@@ -2798,7 +2854,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bumpalo",
|
||||
@@ -2842,9 +2898,9 @@ checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.190"
|
||||
version = "0.2.191"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
|
||||
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
@@ -3457,9 +3513,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.4.4"
|
||||
version = "5.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc"
|
||||
checksum = "8d8c8b8e725ad26404d6896eb2f064294a0a7b4f1d9120210d85544684c4ca95"
|
||||
dependencies = [
|
||||
"is-wsl",
|
||||
"libc",
|
||||
@@ -3846,15 +3902,6 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "presser"
|
||||
version = "0.3.1"
|
||||
@@ -3911,16 +3958,17 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
|
||||
|
||||
[[package]]
|
||||
name = "proptest"
|
||||
version = "1.11.0"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
|
||||
checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0"
|
||||
dependencies = [
|
||||
"bit-set 0.8.0",
|
||||
"bit-vec 0.8.0",
|
||||
"bit-set 0.11.1",
|
||||
"bit-vec 0.10.1",
|
||||
"bitflags 2.13.2",
|
||||
"chacha20",
|
||||
"core_detect",
|
||||
"num-traits",
|
||||
"rand 0.9.5",
|
||||
"rand_chacha",
|
||||
"rand",
|
||||
"rand_xorshift",
|
||||
"regex-syntax",
|
||||
"rusty-fork",
|
||||
@@ -3994,7 +4042,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand 0.10.3",
|
||||
"rand",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash 2.1.3",
|
||||
@@ -4042,16 +4090,6 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.3"
|
||||
@@ -4063,31 +4101,12 @@ dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
@@ -4105,11 +4124,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand_xorshift"
|
||||
version = "0.4.0"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
|
||||
checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf"
|
||||
dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4347,9 +4366,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.20.0"
|
||||
version = "0.21.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
||||
checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
@@ -4612,9 +4634,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.151"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
@@ -4701,9 +4723,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.0"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
@@ -4838,7 +4860,7 @@ dependencies = [
|
||||
"const-oid 0.9.6",
|
||||
"der",
|
||||
"hex",
|
||||
"rand 0.10.3",
|
||||
"rand",
|
||||
"reqwest",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
@@ -6399,7 +6421,7 @@ version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6408,16 +6430,7 @@ version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
|
||||
dependencies = [
|
||||
"windows-targets 0.53.5",
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6435,31 +6448,14 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.52.6",
|
||||
"windows_aarch64_msvc 0.52.6",
|
||||
"windows_i686_gnu 0.52.6",
|
||||
"windows_i686_gnullvm 0.52.6",
|
||||
"windows_i686_msvc 0.52.6",
|
||||
"windows_x86_64_gnu 0.52.6",
|
||||
"windows_x86_64_gnullvm 0.52.6",
|
||||
"windows_x86_64_msvc 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.53.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows_aarch64_gnullvm 0.53.1",
|
||||
"windows_aarch64_msvc 0.53.1",
|
||||
"windows_i686_gnu 0.53.1",
|
||||
"windows_i686_gnullvm 0.53.1",
|
||||
"windows_i686_msvc 0.53.1",
|
||||
"windows_x86_64_gnu 0.53.1",
|
||||
"windows_x86_64_gnullvm 0.53.1",
|
||||
"windows_x86_64_msvc 0.53.1",
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6477,96 +6473,48 @@ version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
|
||||
[[package]]
|
||||
name = "winit"
|
||||
version = "0.30.13"
|
||||
@@ -6659,9 +6607,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "winresource"
|
||||
version = "0.1.31"
|
||||
version = "0.1.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087"
|
||||
checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b"
|
||||
dependencies = [
|
||||
"toml",
|
||||
"version_check",
|
||||
@@ -6716,7 +6664,7 @@ dependencies = [
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"open",
|
||||
"pollster 0.4.0",
|
||||
"pollster 1.0.1",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"windows",
|
||||
@@ -6753,7 +6701,8 @@ version = "0.11.0"
|
||||
name = "wordcraft-doc"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"base64 0.23.1",
|
||||
"log",
|
||||
"proptest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -6770,7 +6719,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"image",
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
"miniz_oxide 0.9.1",
|
||||
"proptest",
|
||||
"serde_json",
|
||||
"sha1 0.11.0",
|
||||
@@ -6784,7 +6733,7 @@ name = "wordcraft-docx"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.22.1",
|
||||
"base64 0.23.1",
|
||||
"cfb",
|
||||
"chrono",
|
||||
"getrandom 0.3.4",
|
||||
@@ -6794,7 +6743,7 @@ dependencies = [
|
||||
"proptest",
|
||||
"quick-xml 0.41.0",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.0",
|
||||
"sha2 0.11.1",
|
||||
"thiserror 2.0.21",
|
||||
"unicode-bidi",
|
||||
"wordcraft-doc",
|
||||
@@ -6838,6 +6787,7 @@ dependencies = [
|
||||
name = "wordcraft-fonts"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"brotli-decompressor",
|
||||
"harfrust 0.13.3",
|
||||
"kurbo",
|
||||
"log",
|
||||
@@ -6939,7 +6889,7 @@ name = "wordcraft-proof"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"miniz_oxide 0.8.9",
|
||||
"miniz_oxide 0.9.1",
|
||||
"proptest",
|
||||
"unicode-normalization",
|
||||
]
|
||||
@@ -6967,6 +6917,7 @@ dependencies = [
|
||||
"craft-i18n",
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
"craft-window",
|
||||
"egui",
|
||||
"egui_kittest",
|
||||
"image",
|
||||
|
||||
+10
-6
@@ -52,14 +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-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "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
|
||||
@@ -71,7 +75,7 @@ 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
|
||||
@@ -86,7 +90,7 @@ 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"
|
||||
@@ -116,7 +120,7 @@ debug = 0
|
||||
|
||||
# Required by craft-pdf: the backend rejects writers that failed during unwinding.
|
||||
[patch.crates-io]
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "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" }
|
||||
|
||||
@@ -101,7 +101,7 @@ list (author, source, license) is in ATTRIBUTION.md.
|
||||
Rust dependencies (egui, eframe, vello_cpu, skrifa, harfrust, krilla, quick-xml, zip, image, …)
|
||||
are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io or pinned Git sources.
|
||||
The craft-pdf writer and Krilla 0.8.2 backend are pinned to storytold/craft-libs revision
|
||||
17744430be327d0257496a6a31c8d38fe20a6756. The backend retains its MIT OR Apache-2.0 licensing
|
||||
b926b5c03e256c86ca10934f58d03f628810b5b7. The backend retains its MIT OR Apache-2.0 licensing
|
||||
and upstream bundled material; see that revision's licenses/pdf-writer/ and ATTRIBUTION.md.
|
||||
The backend patch prevents finalization during an unwind and rejects a writer after failed
|
||||
cleanup. WordCraft enables only writing; PDF import and its image decoders are not enabled.
|
||||
|
||||
@@ -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
|
||||
|
||||
+14
-4
@@ -4,7 +4,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: egui 0.37 migration records the `craft-i18n.rev` catalog pin (9205108…; 27 languages) and the input-method handling; managed CI passed; native acceptance and native-speaker review remain open; previously trivial: progress log: regex Replace expands capture groups, #4177; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: progress log: justified lines ending in a manual line break reach the margin, #3924; previously trivial: progress log: the parity catalog counts Read Mode's View menu, catalog 504/520; previously trivial: progress log: AutoFormat As You Type turns `*bold*` and `_italic_` marks into formatting; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: progress log: Update Table keeps a two-column TOC from a localized Word in its columns, #3889; previously trivial: progress log: comment times in local time, #2322, #2337; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: progress log: AutoCorrect corrects a word typed after a no-break or ideographic space; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously minor: progress log: Spanish proofing, #3360, #3553; previously trivial: progress log: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes toLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: Line truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
|
||||
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
|
||||
|
||||
@@ -20,7 +20,7 @@ agent control. This page is the summary; the assessment is
|
||||
|
||||
| Number | Value | Kind |
|
||||
| --- | --- | --- |
|
||||
| Ribbon/menu catalog coverage | **502 / 513 (97.9%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
|
||||
| Ribbon/menu catalog coverage | **493 / 508 (97.0%)** | measured (`cargo xtask parity` → [`docs/parity-checklist.md`](docs/parity-checklist.md)) |
|
||||
| **Feature breadth** (weighted, incl. dialog options, styles, languages beyond the ribbon) | **~80%** | estimated |
|
||||
| **Ready for real work** (full target) | **~60%** (55–63%) | estimated, additive weighted sum over the dimensions |
|
||||
| **Mainstream practitioner** | **~55%** | estimated: weekly-work depth × discounts for interaction (×0.93), stability (×0.90), file exchange (×0.90) |
|
||||
@@ -140,6 +140,11 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
|
||||
|
||||
## Progress log
|
||||
|
||||
- 2026-10-22: View › Window › New Window opens a second window on the document (desktop). The windows are titled `Name:1` and `Name:2` and each keeps its own caret, scroll position, zoom and view; edits and Undo are the document's and show in both. Closing one of them asks nothing; the last one asks about unsaved changes, and quitting asks once per document.
|
||||
- 2026-10-22: One native window per open document on the desktop. File › New and Open (from the ribbon, File, shortcuts, the file manager, the command line or the control channel) open the document in a window of its own, focused and placed a step below and right of the last one; opening a file replaces a blank document nobody has used in its own window, and a file that is already open brings its window to the front. Each window keeps its own ribbon tab, File page, dialogs, panes, scroll position and composition. View › Window › Switch Windows lists the windows and brings the chosen one forward. File › Close and a window's close button close that window, asking about unsaved changes first; the last window stays open with no document. Quitting asks about each document with unsaved changes in its own window, and Cancel keeps everything open. The control channel's input and screenshots go to the window focused last. The web app keeps one window.
|
||||
- 2026-10-22: Recovery covers every open document. Each dirty document keeps its own copy, written on the same ten-second cadence whether it is shown or not; closing a document without saving deletes its copy, as saving does, and a clean exit deletes the copies of every document saved or closed since its last copy. File › Recover lists only copies no open document keeps, and Restore opens a copy as another document (or in place of an untouched blank one), so it never replaces work. After a crash with several unsaved documents, the next start lists all of them.
|
||||
- 2026-10-22: The desktop window works with several open documents (one window per document comes next). View › Window › Switch Windows lists them and shows the one chosen, back where it was left; New, Open and the mailings open beside unsaved work instead of asking about it; File › Close shows the next document; quitting asks about every document with unsaved changes, one at a time, and Cancel keeps the app open.
|
||||
- 2026-10-22: Several open documents in one session (engine part of one window per document). File › New opens a new document beside the open ones; File › Open opens beside them too, replaces a blank document nobody has used, and shows a file that is already open instead of opening it twice. Each document keeps its own text, selection, undo history, find state, view and recovery copy. View › Window › Switch Windows (`view.switchWindows`) lists the open documents and switches between them; `file.closeDocument` closes one where no window asks about unsaved changes (agents, headless sessions). Envelopes, Labels, Finish & Merge and New from Template open their result as a new document and leave the main document open as it was. Document ids are unique in the process. The desktop app still shows one document per window; the windows come next.
|
||||
- 2026-10-25: Interface catalogs are still the shared storytold/craft-i18n ones embedded from `craft-i18n.rev` (`9205108c…`): 27 WordCraft languages with English fallback for missing strings, and the equation “Delete Around Caret in Equation” translation preserved. Native-speaker review remains human.
|
||||
- 2026-10-25: Input methods (egui 0.37): character- and byte-counted surrounding deletions, a fresh composition after body/equation replacement, rejection of queued stale replacements before mutation, transactional equation deletes and one undo group, with tracked and placeholder length mapping. Synthetic regressions document these paths; managed CI passed, and native IME acceptance (#155–#164) plus browser IME behavior remain open.
|
||||
- 2026-10-25: Text Highlight Color shows the last colour as the button's bar in the ribbon and mini toolbar; after No Color it shows the pen without a misleading yellow bar, and the button removes highlighting (#4087).
|
||||
@@ -210,6 +215,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
| 2026-10-20 | Restrict Editing holds for the file commands too: the privacy options, the properties and the inspector's removals are refused on a protected document, and restoring a stored version keeps the protection and its password instead of the snapshot's stale ones |
|
||||
| 2026-10-17 | Ribbon hints' reasons for a greyed-out control are translated in every interface language; a test fails if a reason it finds in the command sources, or the "Open a document first" hint, has no translation (#2678). |
|
||||
| 2026-10-17 | Ribbon hover hints: a disabled control says why (the command's own reason), split-button arrows, menu buttons and the font, size and outline boxes have hints, and hints show on keyboard focus (#1086). |
|
||||
| 2026-10-16 | Windows per-user installer (#39): each release also ships `wordcraft-<ver>-windows-user-<arch>.msi`, built from the same WiX source as a dual-purpose package, so it installs into `%LOCALAPPDATA%\Programs` for the current user with no administrator rights; registry keys moved to HKMU so both installers share one set of components |
|
||||
| 2026-10-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. |
|
||||
| 2026-10-14 | Tab stops edited on the ruler: the corner selector picks Left/Center/Right/Decimal/Bar, a click sets a stop (Alt skips the snap), dragging moves it with a guide line and position tooltip, dragging off the ruler clears it; markers draw per kind and mirror for RTL; one `para.tabs` call and one undo step per gesture |
|
||||
| 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 |
|
||||
@@ -307,7 +313,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
|
||||
|---|---|
|
||||
| 2026-10-18 | Explicit New/Open keeps the requested document visible when startup recovery discovery finishes; older copies stay available. Recover uses the regular theme. See docs/recovery.md. |
|
||||
| 2026-10-15 | A floating picture or shape in a table cell set to align centre or bottom no longer lands half its height above its line (#1887); it starts at the anchor line |
|
||||
| 2026-10-11 | View › Window across WordCraft windows (#322): Switch Windows, Arrange All, View Side by Side, Synchronous Scrolling |
|
||||
| 2026-10-26 | View › Window across the native windows (#322): Arrange All, View Side by Side, Synchronous Scrolling |
|
||||
| 2026-10-18 | Picture Format › Size (#2573): width and height in the interface unit, and a size typed in any unit works, pixels too (`300 px` keeps the picture's proportions, #1901), applied once typed (Enter, Tab or a click elsewhere; Esc cancels) in one undo step. The group's launcher opens a Size dialog: height and width, scale of the original size (or of the size it opened with), Lock aspect ratio, the original size with its pixels, and Reset. `picture.size {"original": true, "scale"?}` sizes from the original; sizes past 4000 pt that keep the proportions shrink as a whole. |
|
||||
| 2026-10-16 | Save Changes gives assistive technology a named alert dialog with the loss warning and any save failure; visual layout and save policy unchanged (#1015). Native screen-reader verification remains pending. |
|
||||
| 2026-10-17 | `design.scaleFontSizes` (Design › Document Formatting): grow or shrink every style's font size at once, by points (`delta`) or percent, rounded to half points and clamped to 1–1638 pt; inherited sizes follow, runs are untouched, one undo step (#1475) |
|
||||
@@ -638,6 +644,10 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-22 | trivial | Progress log: one native window per open document |
|
||||
| 2026-10-22 | trivial | Progress log: every open document keeps its own recovery copy |
|
||||
| 2026-10-22 | trivial | Progress log: several open documents in the desktop window |
|
||||
| 2026-10-22 | trivial | Progress log: several open documents in one session |
|
||||
| 2026-10-25 | trivial | Progress log: egui 0.37 migration records the `craft-i18n.rev` catalog pin (9205108…; 27 languages) and the input-method handling; managed CI passed; native acceptance and native-speaker review remain open |
|
||||
| 2026-10-17 | trivial | Progress log: Standard-encrypted (AES) Word files open (gap 9) |
|
||||
| 2026-10-25 | trivial | Progress log: Text Highlight Color buttons show the remembered colour, or no colour bar after No Color (#4087) |
|
||||
@@ -703,7 +713,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
|
||||
| 2026-10-17 | trivial | A line breaks after a hyphen before a digit, like Word (#2712) |
|
||||
| 2026-10-15 | trivial | Progress log: text above a float's anchor wraps around it, #2048 |
|
||||
| 2026-10-18 | trivial | Progress log: long navigation headings wrap within the pane instead of ending in an ellipsis (#3015) |
|
||||
| 2026-10-11 | trivial | View › Window across WordCraft windows (#322): View catalog 25/29 → 29/29, total 493/504 (97.8%) |
|
||||
| 2026-10-26 | trivial | View › Window across the native windows (#322): View catalog 26/29 → 29/29 |
|
||||
| 2026-10-18 | trivial | Font list starts at its first row after clearing a no-match query; recent heading remains visible (#2068) |
|
||||
| 2026-10-18 | trivial | A word after a slash wraps whole, like Word (#2963) |
|
||||
| 2026-10-19 | trivial | Progress log: render threads reused while typing (#3027) |
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -73,11 +73,11 @@ 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"] }
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,7 @@ pub fn start(port: u16, ctx: egui::Context, settings: Option<&Path>) -> Option<(
|
||||
}
|
||||
|
||||
/// A new key: 32 random bytes from the operating system, as hex.
|
||||
pub(crate) fn random_key() -> Result<String, String> {
|
||||
fn random_key() -> Result<String, String> {
|
||||
let mut b = [0u8; 32];
|
||||
getrandom::fill(&mut b).map_err(|e| e.to_string())?;
|
||||
Ok(b.iter().map(|x| format!("{x:02x}")).collect())
|
||||
@@ -83,7 +83,7 @@ pub(crate) fn random_key() -> Result<String, String> {
|
||||
|
||||
/// Write the key file: a stale one (from a run that did not exit cleanly) is replaced; on Unix
|
||||
/// the new file is created with mode 0600.
|
||||
pub(crate) fn write_key_file(p: &Path, key: &str) -> Result<(), String> {
|
||||
fn write_key_file(p: &Path, key: &str) -> Result<(), String> {
|
||||
if let Some(d) = p.parent() {
|
||||
std::fs::create_dir_all(d).map_err(|e| format!("cannot create {}: {e}", d.display()))?;
|
||||
}
|
||||
@@ -98,7 +98,7 @@ pub(crate) fn write_key_file(p: &Path, key: &str) -> Result<(), String> {
|
||||
|
||||
/// Compare a key with the window's in time that does not depend on where they differ. An
|
||||
/// empty key never matches.
|
||||
pub(crate) fn keys_match(given: &str, key: &str) -> bool {
|
||||
fn keys_match(given: &str, key: &str) -> bool {
|
||||
let (a, b) = (given.as_bytes(), key.as_bytes());
|
||||
!b.is_empty() && a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
|
||||
}
|
||||
@@ -117,7 +117,7 @@ fn serve_on(listener: TcpListener, ctx: egui::Context, key: String, first: Durat
|
||||
|
||||
/// The read side of a connection with an optional overall deadline: each read waits only for
|
||||
/// the time left, so blank lines, bad JSON or a slow drip of bytes cannot extend it.
|
||||
pub(crate) struct Timed {
|
||||
struct Timed {
|
||||
s: TcpStream,
|
||||
deadline: Option<Instant>,
|
||||
}
|
||||
@@ -133,11 +133,6 @@ impl Read for Timed {
|
||||
}
|
||||
|
||||
impl Timed {
|
||||
/// Reads from `s` that all end by `deadline`.
|
||||
pub(crate) fn new(s: TcpStream, deadline: Instant) -> Self {
|
||||
Timed { s, deadline: Some(deadline) }
|
||||
}
|
||||
|
||||
fn set_deadline(&mut self, d: Option<Instant>) {
|
||||
self.deadline = d;
|
||||
if d.is_none() {
|
||||
|
||||
+21
-21
@@ -23,7 +23,6 @@ mod print;
|
||||
mod region;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod traffic_lights;
|
||||
mod windows;
|
||||
|
||||
use wordcraft_engine::Session;
|
||||
use wordcraft_ui_egui::{
|
||||
@@ -33,9 +32,8 @@ use wordcraft_ui_egui::{
|
||||
|
||||
/// 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, this frame's raw host window state, this window's
|
||||
/// entry in the window registry (removed on exit; View › Window, #322) 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>,
|
||||
@@ -44,7 +42,6 @@ struct App(
|
||||
// The host's answer for this frame. `InputState` still describes the last painted frame on
|
||||
// a hidden logic tick.
|
||||
RawWindowState,
|
||||
Option<windows::RegistryFile>,
|
||||
#[cfg(target_os = "macos")] fmv_macos_events::Inbox,
|
||||
);
|
||||
|
||||
@@ -54,7 +51,7 @@ impl eframe::App for App {
|
||||
/// 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.6, &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;
|
||||
@@ -65,8 +62,17 @@ impl eframe::App for App {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
|
||||
}
|
||||
// The geometry saved for the next start is the active window's.
|
||||
let prev = self.0.ui.window;
|
||||
self.0.ui.window = WindowGeometry::track(prev, &window);
|
||||
// Another document window's state is the one it last painted with: the host reports only
|
||||
// the root window's for this frame.
|
||||
let active = self.0.active_viewport();
|
||||
let tracked = if active == egui::ViewportId::ROOT {
|
||||
window
|
||||
} else {
|
||||
ctx.input_for(active, |i| RawWindowState::from_viewport(Some(i.viewport()), Some(i.viewport_rect())))
|
||||
};
|
||||
self.0.ui.window = WindowGeometry::track(prev, &tracked);
|
||||
// The first frame, once the monitor is known (as `take_rescue` waits for): maximize a
|
||||
// window that opened at its normal size so it never flashed on screen half-opened.
|
||||
let maximize = self.1.and_then(|g| g.maximize_after_open()).filter(|_| window.monitor_size.is_some());
|
||||
@@ -76,9 +82,11 @@ impl eframe::App for App {
|
||||
if let Some(command) = maximize {
|
||||
ctx.send_viewport_cmd(command);
|
||||
}
|
||||
// The window's close button: with unsaved changes, ask first (`WordApp::close_requested`).
|
||||
// The window's close button: with other windows open only this window closes (the app
|
||||
// handles that while drawing it); the last one quits, asking first about unsaved changes
|
||||
// (`WordApp::close_requested`).
|
||||
let closing = ctx.input(|i| i.viewport().close_requested());
|
||||
if closing && !self.0.close_requested() {
|
||||
if closing && (self.0.closes_a_window() || !self.0.close_requested()) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
} else if (closing || self.0.quit_requested) && !self.0.printing.busy() {
|
||||
// On macOS, letting eframe drop the window inside `-[NSApplication run]` aborts in
|
||||
@@ -115,9 +123,6 @@ impl eframe::App for App {
|
||||
if let Some(key_file) = &self.2 {
|
||||
key_file.remove();
|
||||
}
|
||||
if let Some(entry) = &self.5 {
|
||||
entry.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,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);
|
||||
@@ -455,14 +462,7 @@ fn main() -> eframe::Result {
|
||||
let (file_dialog, dialogs) = file_dialogs::hook(&cc.egui_ctx);
|
||||
let mut session = Session::new(doc);
|
||||
session.system_region = region;
|
||||
let mut services = services(file_dialog, &cc.egui_ctx);
|
||||
// The other WordCraft windows, for View › Window (#322).
|
||||
let registry = wordcraft_control_key::settings_dir().and_then(|d| windows::start(&d.join("windows"), cc.egui_ctx.clone()));
|
||||
let registry = registry.map(|(host, file)| {
|
||||
services.windows = Some(Box::new(host));
|
||||
file
|
||||
});
|
||||
let mut app = WordApp::new(session, services);
|
||||
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);
|
||||
@@ -482,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() {
|
||||
@@ -516,7 +517,6 @@ fn main() -> eframe::Result {
|
||||
key_file,
|
||||
dialogs,
|
||||
RawWindowState::default(),
|
||||
registry,
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events.connect(&cc.egui_ctx),
|
||||
)))
|
||||
|
||||
@@ -1,456 +0,0 @@
|
||||
//! The desktop app's side of View › Window (#322): a registry of the running WordCraft windows
|
||||
//! and a small authenticated loopback channel between them.
|
||||
//!
|
||||
//! Each window is its own process with one document. At start it binds `127.0.0.1` on a free
|
||||
//! port, picks a random id and key, and writes `<settings>/windows/<id>.json` (mode 0600 on Unix):
|
||||
//! `{"id", "port", "key", "title"}`. The file is removed when the window closes. Other windows
|
||||
//! list the folder in the background: an entry whose port refuses connections, or that doesn't
|
||||
//! answer and hasn't been rewritten for two minutes (each window rewrites its entry every 30 s), is
|
||||
//! left over from a crash and is removed; unreadable, oversized or malformed entries are ignored.
|
||||
//!
|
||||
//! The channel takes one JSON line per connection, `{"key", "from", "msg"}` with `msg` a
|
||||
//! [`PeerMessage`], and nothing else: it can bring the window to the front, place it and scroll
|
||||
//! it in step with its Side by Side partner, never run commands or read the document. Lines are
|
||||
//! at most 4 KiB, the key is compared in constant time, a connection has 2 s to send its line,
|
||||
//! and at most 16 are served at once. The full control channel (`--control`) stays opt-in.
|
||||
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_engine::cmd::view::OtherWindow;
|
||||
use wordcraft_ui_egui::windows::{Envelope, PeerMessage, WindowHost};
|
||||
|
||||
use crate::control_server::{Timed, keys_match, random_key, write_key_file};
|
||||
|
||||
/// Longest request or reply line, and largest registry entry read.
|
||||
const MAX_LINE: usize = 4096;
|
||||
/// How long a connection has to send its line.
|
||||
const FIRST_LINE: Duration = Duration::from_secs(2);
|
||||
/// Connect and reply timeout when talking to another window.
|
||||
const IO_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
/// How often the list of other windows is refreshed.
|
||||
const REFRESH: Duration = Duration::from_secs(1);
|
||||
/// At most this many other windows are listed, and this many connections served at once.
|
||||
const MAX_WINDOWS: usize = 64;
|
||||
const MAX_CONNECTIONS: usize = 16;
|
||||
/// Titles are cut to this many characters.
|
||||
const MAX_TITLE: usize = 200;
|
||||
/// Each window rewrites its entry this often…
|
||||
const HEARTBEAT: Duration = Duration::from_secs(30);
|
||||
/// …so an entry that doesn't answer and is older than this is left over from a crash.
|
||||
const STALE: Duration = Duration::from_secs(120);
|
||||
|
||||
/// A registry entry: how to reach a window.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Entry {
|
||||
id: u64,
|
||||
port: u16,
|
||||
key: String,
|
||||
title: String,
|
||||
/// When the file was last written (not part of it).
|
||||
modified: Option<std::time::SystemTime>,
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
fn to_json(&self) -> Value {
|
||||
json!({"id": self.id, "port": self.port, "key": self.key, "title": self.title})
|
||||
}
|
||||
|
||||
/// The entry in a registry file named `<stem>.json`, if it is well formed: the id matches
|
||||
/// the file name, the port isn't 0, the key is 64 hex digits. The title loses control
|
||||
/// characters and is cut to [`MAX_TITLE`] characters.
|
||||
fn parse(stem: &str, text: &str) -> Option<Entry> {
|
||||
let v: Value = serde_json::from_str(text).ok()?;
|
||||
let id = v.get("id")?.as_u64().filter(|id| id.to_string() == stem)?;
|
||||
let port = v.get("port")?.as_u64().and_then(|p| u16::try_from(p).ok()).filter(|p| *p != 0)?;
|
||||
let key = v.get("key")?.as_str().filter(|k| k.len() == 64 && k.bytes().all(|b| b.is_ascii_hexdigit()))?.to_string();
|
||||
let title = v.get("title").and_then(Value::as_str).unwrap_or("").chars().filter(|c| !c.is_control()).take(MAX_TITLE).collect();
|
||||
Some(Entry { id, port, key, title, modified: None })
|
||||
}
|
||||
|
||||
fn other(&self) -> OtherWindow {
|
||||
let title = if self.title.trim().is_empty() { "WordCraft".to_string() } else { self.title.clone() };
|
||||
OtherWindow { id: self.id, title }
|
||||
}
|
||||
}
|
||||
|
||||
/// The registry file of this window; [`RegistryFile::remove`] it when the window closes.
|
||||
pub struct RegistryFile {
|
||||
path: PathBuf,
|
||||
/// Held while the file is written; `true` once it is removed, so it isn't written again.
|
||||
closed: Arc<Mutex<bool>>,
|
||||
}
|
||||
|
||||
impl RegistryFile {
|
||||
pub fn remove(&self) {
|
||||
let mut closed = self.closed.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
*closed = true;
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
/// This window's [`WindowHost`]: the list the background thread keeps and the queue it sends.
|
||||
pub struct Windows {
|
||||
id: u64,
|
||||
others: Arc<Mutex<Vec<OtherWindow>>>,
|
||||
title: Arc<Mutex<String>>,
|
||||
out: Sender<(u64, PeerMessage)>,
|
||||
incoming: Receiver<Envelope>,
|
||||
}
|
||||
|
||||
impl WindowHost for Windows {
|
||||
fn id(&self) -> u64 {
|
||||
self.id
|
||||
}
|
||||
fn others(&self) -> Vec<OtherWindow> {
|
||||
self.others.lock().map(|o| o.clone()).unwrap_or_default()
|
||||
}
|
||||
fn send(&self, to: u64, msg: PeerMessage) {
|
||||
let _ = self.out.send((to, msg));
|
||||
}
|
||||
fn publish(&self, title: &str) {
|
||||
if let Ok(mut t) = self.title.lock()
|
||||
&& *t != title
|
||||
{
|
||||
*t = title.to_string();
|
||||
}
|
||||
}
|
||||
fn incoming(&self) -> Vec<Envelope> {
|
||||
self.incoming.try_iter().collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Join the registry in `dir`: serve the channel, write this window's entry and keep the list of
|
||||
/// the others in the background. `None` (View › Window stays disabled) when there is no port,
|
||||
/// random key or writable folder.
|
||||
pub fn start(dir: &Path, ctx: egui::Context) -> Option<(Windows, RegistryFile)> {
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).map_err(|e| log::warn!("window channel: {e}")).ok()?;
|
||||
let port = listener.local_addr().ok()?.port();
|
||||
let key = random_key().map_err(|e| log::warn!("window channel: {e}")).ok()?;
|
||||
let id = random_id()?;
|
||||
let me = Entry { id, port, key: key.clone(), title: String::new(), modified: None };
|
||||
let file = RegistryFile { path: dir.join(format!("{id}.json")), closed: Arc::new(Mutex::new(false)) };
|
||||
if let Err(e) = write_entry(dir, &me, &file.closed) {
|
||||
log::warn!("window registry: {e}");
|
||||
return None;
|
||||
}
|
||||
let (tx, incoming) = channel();
|
||||
serve_on(listener, key, tx, ctx);
|
||||
let (out, queue) = channel();
|
||||
let host = Windows { id, others: Arc::default(), title: Arc::default(), out, incoming };
|
||||
let worker = Worker {
|
||||
dir: dir.to_path_buf(),
|
||||
me,
|
||||
closed: file.closed.clone(),
|
||||
others: host.others.clone(),
|
||||
title: host.title.clone(),
|
||||
known: Vec::new(),
|
||||
written: Instant::now(),
|
||||
};
|
||||
std::thread::spawn(move || worker.run(&queue));
|
||||
Some((host, file))
|
||||
}
|
||||
|
||||
/// A random window id that survives JSON numbers (below 2⁵³) and is never 0.
|
||||
fn random_id() -> Option<u64> {
|
||||
let mut b = [0u8; 8];
|
||||
getrandom::fill(&mut b).map_err(|e| log::warn!("window id: {e}")).ok()?;
|
||||
Some((u64::from_le_bytes(b) & ((1 << 53) - 1)).max(1))
|
||||
}
|
||||
|
||||
/// Write `<id>.json` whole (a temporary file renamed over it), unless the window has closed.
|
||||
fn write_entry(dir: &Path, e: &Entry, closed: &Mutex<bool>) -> Result<(), String> {
|
||||
let closed = closed.lock().map_err(|_| "registry lock poisoned".to_string())?;
|
||||
if *closed {
|
||||
return Ok(());
|
||||
}
|
||||
let tmp = dir.join(format!("{}.json.tmp", e.id));
|
||||
write_key_file(&tmp, &e.to_json().to_string())?;
|
||||
std::fs::rename(&tmp, dir.join(format!("{}.json", e.id))).map_err(|err| format!("cannot write {}: {err}", dir.display()))
|
||||
}
|
||||
|
||||
/// The well-formed entries in `dir` other than `me`'s, by id.
|
||||
fn read_entries(dir: &Path, me: u64) -> Vec<Entry> {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
for f in rd.flatten().take(MAX_WINDOWS * 4) {
|
||||
let path = f.path();
|
||||
let Some(stem) = path.file_name().and_then(|n| n.to_str()).and_then(|n| n.strip_suffix(".json")) else { continue };
|
||||
if stem == me.to_string() || !f.metadata().is_ok_and(|m| m.is_file() && m.len() <= MAX_LINE as u64) {
|
||||
continue;
|
||||
}
|
||||
if let Some(e) = std::fs::read_to_string(&path).ok().and_then(|t| Entry::parse(stem, &t)) {
|
||||
out.push(Entry { modified: f.metadata().and_then(|m| m.modified()).ok(), ..e });
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|e| e.id);
|
||||
out
|
||||
}
|
||||
|
||||
/// The entries in `dir` whose windows answer. Entries left over from a crash are removed: their
|
||||
/// port refuses connections, or (Windows answers a closed port only after a few seconds) they
|
||||
/// don't answer and are older than [`STALE`].
|
||||
fn live_entries(dir: &Path, me: u64) -> Vec<Entry> {
|
||||
let mut live = Vec::new();
|
||||
for e in read_entries(dir, me) {
|
||||
match send_to(&e, me, &PeerMessage::Ping) {
|
||||
Ok(()) => live.push(e),
|
||||
Err(err) => {
|
||||
let old = e.modified.and_then(|m| m.elapsed().ok()).is_some_and(|age| age > STALE);
|
||||
if err.kind() == std::io::ErrorKind::ConnectionRefused || old {
|
||||
log::info!("window registry: removing stale entry {}", e.id);
|
||||
let _ = std::fs::remove_file(dir.join(format!("{}.json", e.id)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if live.len() >= MAX_WINDOWS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
live
|
||||
}
|
||||
|
||||
/// Send one message to a window and wait for its answer.
|
||||
fn send_to(e: &Entry, from: u64, msg: &PeerMessage) -> std::io::Result<()> {
|
||||
let mut s = TcpStream::connect_timeout(&SocketAddr::from((Ipv4Addr::LOCALHOST, e.port)), IO_TIMEOUT)?;
|
||||
s.set_write_timeout(Some(IO_TIMEOUT))?;
|
||||
let line = json!({"key": e.key, "from": from, "msg": msg});
|
||||
writeln!(s, "{line}")?;
|
||||
let mut reply = Vec::new();
|
||||
BufReader::new(Timed::new(s, Instant::now() + IO_TIMEOUT)).take(MAX_LINE as u64).read_until(b'\n', &mut reply)?;
|
||||
let ok = serde_json::from_slice::<Value>(&reply).ok().and_then(|v| v.get("ok").and_then(Value::as_bool)) == Some(true);
|
||||
if ok { Ok(()) } else { Err(std::io::Error::other("refused")) }
|
||||
}
|
||||
|
||||
/// The background thread: sends queued messages and refreshes the list and this window's entry.
|
||||
struct Worker {
|
||||
dir: PathBuf,
|
||||
me: Entry,
|
||||
closed: Arc<Mutex<bool>>,
|
||||
others: Arc<Mutex<Vec<OtherWindow>>>,
|
||||
title: Arc<Mutex<String>>,
|
||||
/// The other windows at the last refresh.
|
||||
known: Vec<Entry>,
|
||||
/// When this window's entry was last written.
|
||||
written: Instant,
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
fn run(mut self, queue: &Receiver<(u64, PeerMessage)>) {
|
||||
let mut refreshed: Option<Instant> = None;
|
||||
loop {
|
||||
let mut batch = Vec::new();
|
||||
match queue.recv_timeout(REFRESH) {
|
||||
Ok(m) => {
|
||||
// Scrolling queues a message a frame: take what else arrives right away.
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
batch.push(m);
|
||||
batch.extend(queue.try_iter());
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout) => {}
|
||||
Err(RecvTimeoutError::Disconnected) => return,
|
||||
}
|
||||
for (to, msg) in coalesce(batch) {
|
||||
if !self.known.iter().any(|e| e.id == to) {
|
||||
self.known = read_entries(&self.dir, self.me.id);
|
||||
}
|
||||
if let Some(e) = self.known.iter().find(|e| e.id == to)
|
||||
&& let Err(err) = send_to(e, self.me.id, &msg)
|
||||
{
|
||||
log::info!("window {to}: {err}");
|
||||
}
|
||||
}
|
||||
if refreshed.is_none_or(|t| t.elapsed() >= REFRESH) {
|
||||
self.refresh();
|
||||
refreshed = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh(&mut self) {
|
||||
let title = self.title.lock().map(|t| t.chars().filter(|c| !c.is_control()).take(MAX_TITLE).collect::<String>()).unwrap_or_default();
|
||||
if title != self.me.title || self.written.elapsed() >= HEARTBEAT {
|
||||
self.me.title = title;
|
||||
self.written = Instant::now();
|
||||
if let Err(e) = write_entry(&self.dir, &self.me, &self.closed) {
|
||||
log::warn!("window registry: {e}");
|
||||
}
|
||||
}
|
||||
self.known = live_entries(&self.dir, self.me.id);
|
||||
if let Ok(mut o) = self.others.lock() {
|
||||
*o = self.known.iter().map(Entry::other).collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Queued messages in order, with runs of scrolls to the same window added up.
|
||||
fn coalesce(batch: Vec<(u64, PeerMessage)>) -> Vec<(u64, PeerMessage)> {
|
||||
let mut out: Vec<(u64, PeerMessage)> = Vec::new();
|
||||
for (to, msg) in batch {
|
||||
if let (Some((last_to, PeerMessage::Scroll { dy: sum })), PeerMessage::Scroll { dy }) = (out.last_mut(), &msg)
|
||||
&& *last_to == to
|
||||
{
|
||||
*sum += dy;
|
||||
continue;
|
||||
}
|
||||
out.push((to, msg));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn serve_on(listener: TcpListener, key: String, tx: Sender<Envelope>, ctx: egui::Context) {
|
||||
let key: Arc<str> = key.into();
|
||||
let busy = Arc::new(AtomicUsize::new(0));
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
if busy.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
|
||||
busy.fetch_sub(1, Ordering::SeqCst);
|
||||
continue;
|
||||
}
|
||||
let (key, tx, ctx, busy) = (key.clone(), tx.clone(), ctx.clone(), busy.clone());
|
||||
std::thread::spawn(move || {
|
||||
serve(stream, &key, &tx, &ctx);
|
||||
busy.fetch_sub(1, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// One connection: one line in, one reply out.
|
||||
fn serve(stream: TcpStream, key: &str, tx: &Sender<Envelope>, ctx: &egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
let mut line = Vec::new();
|
||||
let mut reader = BufReader::new(Timed::new(read, Instant::now() + FIRST_LINE));
|
||||
if reader.by_ref().take(MAX_LINE as u64 + 1).read_until(b'\n', &mut line).is_err() {
|
||||
return;
|
||||
}
|
||||
let reply = match handle(&line, key) {
|
||||
Ok(Some(env)) => {
|
||||
let sent = tx.send(env).is_ok();
|
||||
ctx.request_repaint();
|
||||
if sent { json!({"ok": true}) } else { json!({"ok": false, "error": "closing"}) }
|
||||
}
|
||||
Ok(None) => json!({"ok": true}),
|
||||
Err(e) => json!({"ok": false, "error": e}),
|
||||
};
|
||||
let _ = writeln!(out, "{reply}");
|
||||
}
|
||||
|
||||
/// A request line: the message for the UI, `None` for a ping, or why it was refused.
|
||||
fn handle(line: &[u8], key: &str) -> Result<Option<Envelope>, &'static str> {
|
||||
if line.len() > MAX_LINE {
|
||||
return Err("line too long");
|
||||
}
|
||||
let v: Value = serde_json::from_slice(line).map_err(|_| "bad JSON")?;
|
||||
if !keys_match(v.get("key").and_then(Value::as_str).unwrap_or(""), key) {
|
||||
return Err("unauthorized");
|
||||
}
|
||||
let from = v.get("from").and_then(Value::as_u64).ok_or("missing `from`")?;
|
||||
let msg =
|
||||
v.get("msg").cloned().and_then(|m| serde_json::from_value::<PeerMessage>(m).ok()).and_then(PeerMessage::sanitized).ok_or("bad message")?;
|
||||
Ok((msg != PeerMessage::Ping).then_some(Envelope { from, msg }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const KEY: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("wordcraft-windows-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_ignores_hostile_entries_and_drops_stale_ones() {
|
||||
let dir = temp_dir("registry");
|
||||
let write = |name: &str, text: &str| std::fs::write(dir.join(name), text).unwrap();
|
||||
let entry = |id: u64, port: u16| Entry { id, port, key: KEY.into(), title: "Notes\n\u{7}.docx".into(), modified: None }.to_json().to_string();
|
||||
// A window that is gone: nothing listens on its port any more, and it stopped rewriting
|
||||
// its entry (Windows doesn't refuse a closed port right away).
|
||||
let closed_port = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap().local_addr().unwrap().port();
|
||||
write("11.json", &entry(11, closed_port));
|
||||
let hour_ago = std::time::SystemTime::now() - Duration::from_secs(3600);
|
||||
std::fs::File::options().write(true).open(dir.join("11.json")).unwrap().set_modified(hour_ago).unwrap();
|
||||
// Malformed or hostile entries.
|
||||
write("12.json", &entry(99, 4000)); // the id doesn't match the file name
|
||||
write("13.json", r#"{"id": 13, "port": 0, "key": "0123"}"#);
|
||||
write("14.json", r#"{"id": 14, "port": 70000, "key": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}"#);
|
||||
write("15.json", &format!(r#"{{"id": 15, "port": 4000, "key": "{}"}}"#, "z".repeat(64)));
|
||||
write("16.json", "not json");
|
||||
write("17.json", &" ".repeat(MAX_LINE + 1));
|
||||
write("notes.txt", &entry(18, 4000));
|
||||
std::fs::create_dir_all(dir.join("19.json")).unwrap();
|
||||
let listed = read_entries(&dir, 1);
|
||||
assert_eq!(listed.iter().map(|e| e.id).collect::<Vec<_>>(), [11], "only the well-formed entry: {listed:?}");
|
||||
assert_eq!(listed[0].title, "Notes.docx", "control characters are dropped");
|
||||
assert!(
|
||||
Entry::parse("20", &Entry { id: 20, port: 1, key: KEY.into(), title: "x".repeat(10_000), modified: None }.to_json().to_string())
|
||||
.is_some_and(|e| e.title.chars().count() == MAX_TITLE)
|
||||
);
|
||||
assert!(live_entries(&dir, 1).is_empty());
|
||||
assert!(!dir.join("11.json").exists(), "the stale entry is removed");
|
||||
assert!(dir.join("16.json").exists(), "entries that aren't ours to judge are left alone");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_find_and_message_each_other() {
|
||||
let dir = temp_dir("peers");
|
||||
let ctx = egui::Context::default();
|
||||
let (a, a_file) = start(&dir, ctx.clone()).unwrap();
|
||||
let (b, b_file) = start(&dir, ctx).unwrap();
|
||||
a.publish("Notes.docx");
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while !b.others().iter().any(|o| o.id == a.id() && o.title == "Notes.docx") {
|
||||
assert!(Instant::now() < deadline, "b never saw a: {:?}", b.others());
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
b.send(a.id(), PeerMessage::Scroll { dy: 10.0 });
|
||||
b.send(a.id(), PeerMessage::Scroll { dy: 5.0 });
|
||||
b.send(a.id(), PeerMessage::Focus);
|
||||
let mut got = Vec::new();
|
||||
while got.iter().all(|e: &Envelope| e.msg != PeerMessage::Focus) {
|
||||
assert!(Instant::now() < deadline, "a got {got:?}");
|
||||
got.extend(a.incoming());
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
let scrolled: f32 = got.iter().map(|e| if let PeerMessage::Scroll { dy } = e.msg { dy } else { 0.0 }).sum();
|
||||
assert_eq!(scrolled, 15.0);
|
||||
assert!(got.iter().all(|e| e.from == b.id()));
|
||||
// Without the key, or with a bad message, nothing reaches the window.
|
||||
let port = read_entries(&dir, b.id()).iter().find(|e| e.id == a.id()).unwrap().port;
|
||||
let wrong = Entry { id: a.id(), port, key: "f".repeat(64), title: String::new(), modified: None };
|
||||
assert!(send_to(&wrong, 1, &PeerMessage::Focus).is_err());
|
||||
assert_eq!(handle(br#"{"key": "x", "from": 1, "msg": {"type": "focus"}}"#, KEY), Err("unauthorized"));
|
||||
let place = format!(r#"{{"key": "{KEY}", "from": 1, "msg": {{"type": "place", "x": 1e30, "y": 0, "width": 10, "height": 10}}}}"#);
|
||||
assert_eq!(handle(place.as_bytes(), KEY), Err("bad message"));
|
||||
assert_eq!(handle(&vec![b' '; MAX_LINE + 1], KEY), Err("line too long"));
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
assert!(a.incoming().is_empty());
|
||||
// A closed window's entry is gone.
|
||||
a_file.remove();
|
||||
assert!(!dir.join(format!("{}.json", a.id())).exists());
|
||||
b_file.remove();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrolls_add_up_in_order() {
|
||||
let s = |dy| PeerMessage::Scroll { dy };
|
||||
assert_eq!(
|
||||
coalesce(vec![(1, s(1.0)), (1, s(2.0)), (2, s(4.0)), (1, PeerMessage::Focus), (1, s(8.0))]),
|
||||
vec![(1, s(3.0)), (2, s(4.0)), (1, PeerMessage::Focus), (1, s(8.0))]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+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() }
|
||||
|
||||
+88
-23
@@ -12,6 +12,7 @@
|
||||
|
||||
pub mod bidi;
|
||||
mod byte_store;
|
||||
pub mod cfb_guard;
|
||||
pub mod chart;
|
||||
mod cjk_numbers;
|
||||
pub mod cluster;
|
||||
@@ -858,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 {
|
||||
@@ -886,6 +931,7 @@ impl Document {
|
||||
media: BTreeMap::new(),
|
||||
passthrough: BTreeMap::new(),
|
||||
glossary: Vec::new(),
|
||||
media_index: MediaIndex::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -961,6 +1007,7 @@ impl Document {
|
||||
media,
|
||||
passthrough,
|
||||
glossary,
|
||||
media_index: _,
|
||||
} = self;
|
||||
let Document {
|
||||
body: other_body,
|
||||
@@ -979,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)
|
||||
@@ -1101,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() });
|
||||
}
|
||||
}
|
||||
@@ -1562,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
|
||||
}
|
||||
|
||||
@@ -1607,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 };
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -1631,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();
|
||||
}
|
||||
@@ -1645,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 }));
|
||||
}
|
||||
}
|
||||
@@ -1828,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(_)]));
|
||||
}
|
||||
}
|
||||
@@ -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(' '))];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -117,6 +117,12 @@ impl PieceTable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -74,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)) {
|
||||
@@ -87,6 +87,7 @@ 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)?;
|
||||
|
||||
+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;
|
||||
|
||||
+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())]);
|
||||
|
||||
@@ -1363,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;
|
||||
@@ -1374,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
|
||||
|
||||
@@ -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> {
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1231,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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -342,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");
|
||||
}
|
||||
@@ -141,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(),
|
||||
@@ -208,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(),
|
||||
@@ -217,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());
|
||||
@@ -232,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()))?;
|
||||
@@ -260,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);
|
||||
}
|
||||
@@ -269,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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1096,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}))
|
||||
}
|
||||
|
||||
@@ -1261,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}))
|
||||
}
|
||||
|
||||
@@ -1494,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}))
|
||||
}
|
||||
|
||||
@@ -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())));
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -8,9 +8,10 @@ use wordcraft_doc::{Block, ListKind, Path, Pos, StoryRef, Table};
|
||||
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,12 +97,16 @@ fn typing_options(s: &mut Session, text: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts text with the typing options and smart quotes, records the IME outcome, and for a
|
||||
/// single trigger character runs list, AutoCorrect, dash and double-space AutoFormat as a separate undo step
|
||||
/// when they rewrite the paragraph.
|
||||
/// `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 smart_quote = if raw { None } else { autoformat(s, text) };
|
||||
let was_collapsed = s.sel.is_collapsed();
|
||||
@@ -714,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)
|
||||
@@ -734,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 {
|
||||
@@ -773,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);
|
||||
@@ -782,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
|
||||
@@ -916,6 +967,24 @@ fn backspace(s: &mut Session, v: &Value) -> CmdResult {
|
||||
/// 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;
|
||||
@@ -949,22 +1018,8 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult {
|
||||
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.
|
||||
|
||||
@@ -551,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)
|
||||
})
|
||||
@@ -1554,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() {
|
||||
@@ -1586,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));
|
||||
@@ -1601,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 {
|
||||
|
||||
@@ -110,6 +110,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)
|
||||
@@ -129,22 +133,17 @@ 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)
|
||||
})
|
||||
.key("Mod+Alt+S")
|
||||
.pure(),
|
||||
// View › Window across WordCraft windows (#322): each window is its own process; the
|
||||
// desktop app performs these (see `Windows`), elsewhere they are disabled.
|
||||
CommandSpec::new("view.switchWindows", "Switch Windows", "View › Window", |s, v| {
|
||||
let id = pick_window(s, v)?;
|
||||
s.ui_requests.push(json!({"windows": "focus", "id": id}));
|
||||
Ok(json!({"window": id}))
|
||||
})
|
||||
.params(r#"{"window"?: id from ui.inspect `windows.others` (optional when one other window is open)}"#)
|
||||
.when(has_other_window)
|
||||
.pure(),
|
||||
// View › Window across WordCraft windows (#322): the desktop app's native windows
|
||||
// perform these (see `Windows`), elsewhere they are disabled.
|
||||
CommandSpec::new("view.arrangeAll", "Arrange All", "View › Window", |s, _| {
|
||||
s.ui_requests.push(json!({"windows": "arrange"}));
|
||||
Ok(json!({"windows": s.windows.others.len() + 1}))
|
||||
@@ -196,9 +195,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// View › Window (#322): the other WordCraft windows as the desktop app last saw them, and the
|
||||
/// Side by Side pairing. The front end keeps `available` and `others` current; elsewhere (web,
|
||||
/// CLI, MCP without the app) they stay empty and the window commands are disabled.
|
||||
/// View › Window (#322): the other native windows of the app as the window being drawn sees them
|
||||
/// (by window id, the ids of `ui.inspect`'s `windows`), and its Side by Side pairing. The front
|
||||
/// end keeps these current as each window draws; elsewhere (web, CLI, MCP without the app) they
|
||||
/// stay empty and the window commands are disabled.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Windows {
|
||||
@@ -287,6 +287,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,
|
||||
@@ -395,3 +405,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();
|
||||
|
||||
+18
-1
@@ -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))
|
||||
@@ -507,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('/')) {
|
||||
@@ -521,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +28,8 @@ 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,
|
||||
next_math_session, 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.
|
||||
///
|
||||
@@ -200,6 +200,8 @@ mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_bidi;
|
||||
#[cfg(test)]
|
||||
mod tests_documents;
|
||||
#[cfg(test)]
|
||||
mod tests_fonts;
|
||||
#[cfg(test)]
|
||||
mod tests_ime_noop;
|
||||
|
||||
+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]);
|
||||
}
|
||||
}
|
||||
+325
-90
@@ -25,6 +25,12 @@ use wordcraft_layout::{DocLayout, LayoutCache, LayoutOptions, NumeralMode, ViewM
|
||||
|
||||
use crate::{CmdError, ImeEditOutcome, Registry};
|
||||
|
||||
mod documents;
|
||||
mod views;
|
||||
pub use documents::{DocInfo, DocState};
|
||||
use documents::{document_id_mark, next_document_id, untitled_name};
|
||||
pub use views::WindowView;
|
||||
|
||||
/// A selection: anchor (where it started) and focus (where the caret is).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct Selection {
|
||||
@@ -135,6 +141,10 @@ pub struct ViewState {
|
||||
pub table_gridlines: bool,
|
||||
pub nav_pane: bool,
|
||||
pub styles_pane: bool,
|
||||
/// Where the Styles pane sits: docked right (the default) or left, or floating (#1756).
|
||||
/// Kept in the user's preferences, not the document.
|
||||
#[serde(default)]
|
||||
pub styles_pane_dock: StylesPaneDock,
|
||||
/// Style Inspector pane (Home › Styles).
|
||||
#[serde(default)]
|
||||
pub style_inspector: bool,
|
||||
@@ -215,6 +225,38 @@ pub struct ViewState {
|
||||
pub full_screen: bool,
|
||||
}
|
||||
|
||||
/// Where the Styles pane sits (`view.stylesPaneDock`, #1756).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum StylesPaneDock {
|
||||
#[default]
|
||||
Right,
|
||||
Left,
|
||||
/// A window of its own over the document, which can be moved and resized.
|
||||
#[serde(rename = "float")]
|
||||
Floating,
|
||||
}
|
||||
|
||||
impl StylesPaneDock {
|
||||
/// `left`, `right` or `float`; anything else is `None`.
|
||||
pub fn parse(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
"right" => Some(Self::Right),
|
||||
"left" => Some(Self::Left),
|
||||
"float" => Some(Self::Floating),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Right => "right",
|
||||
Self::Left => "left",
|
||||
Self::Floating => "float",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on() -> bool {
|
||||
true
|
||||
}
|
||||
@@ -252,6 +294,7 @@ impl Default for ViewState {
|
||||
table_gridlines: true,
|
||||
nav_pane: false,
|
||||
styles_pane: false,
|
||||
styles_pane_dock: StylesPaneDock::Right,
|
||||
style_inspector: false,
|
||||
comments_pane: false,
|
||||
clipboard_pane: false,
|
||||
@@ -372,7 +415,38 @@ impl EditSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
/// An editing session.
|
||||
/// What `run` keeps to take a failed mutating command back: the document, the selection, the
|
||||
/// undo and redo stacks, the open typing group, the undo steps dropped so far and the column
|
||||
/// block.
|
||||
type RunSnapshot = (Document, Selection, Vec<Arc<Undo>>, Vec<Arc<Undo>>, bool, u64, Option<ColumnBlock>);
|
||||
|
||||
/// A layout made and what it was made for: (revision, width, read height, view, read line
|
||||
/// spacing, layout, proofing, outline, (deletions left out, deletions marked Hidden, body
|
||||
/// deletions in balloons), numeral display, the word being typed, (hidden text shown, field
|
||||
/// codes shown)).
|
||||
type LayoutEntry = (
|
||||
u64,
|
||||
f32,
|
||||
f32,
|
||||
ViewMode,
|
||||
Option<u32>,
|
||||
Arc<DocLayout>,
|
||||
bool,
|
||||
Option<wordcraft_layout::outline::OutlineView>,
|
||||
(bool, wordcraft_layout::HideDeleted, bool),
|
||||
NumeralMode,
|
||||
Option<wordcraft_layout::TypingWord>,
|
||||
(bool, bool),
|
||||
);
|
||||
|
||||
/// The layouts last made, newest first. Two, so two windows on one document with different
|
||||
/// views ([`WindowView`]) each find theirs as they draw in turn.
|
||||
type LayoutSlots = [Option<LayoutEntry>; 2];
|
||||
|
||||
/// An editing session: the app-wide state (clipboard, preferences, macros, the command registry…)
|
||||
/// and the open documents. The active document's state is in the session's own fields, which
|
||||
/// every command works on; the other open documents wait in [`DocState`] values and are swapped
|
||||
/// in when they become active (see [`Session::documents`]).
|
||||
pub struct Session {
|
||||
pub doc: Document,
|
||||
pub sel: Selection,
|
||||
@@ -446,13 +520,18 @@ pub struct Session {
|
||||
/// The newest automatic capitalisation, for Word's "Automatically add words to list" (#1857).
|
||||
pub(crate) autocap: Option<crate::cmd::tools::AutoCap>,
|
||||
rev: u64,
|
||||
/// Which document this is, unique in the process: changes when [`Session::set_document`]
|
||||
/// replaces it (an open into an untouched blank document, recover), never on an edit or when
|
||||
/// another document becomes active and this one comes back.
|
||||
document_id: u64,
|
||||
/// The number in an untitled document's name (`Document2`); 0 for one opened from a file.
|
||||
untitled: u32,
|
||||
/// `rev` when the document was made, for [`Session::is_untouched_blank`].
|
||||
made_rev: u64,
|
||||
/// What Restrict Editing's outright refusals depend on, for greying out commands
|
||||
/// ([`Session::why_disabled`]): computed once per document, revision and author instead of
|
||||
/// for every button on every frame.
|
||||
protect_facts: std::sync::Mutex<Option<((u64, u64, String), crate::cmd::protect::Facts)>>,
|
||||
/// Which document this is: changes when [`Session::set_document`] replaces it (open, new,
|
||||
/// mail merge, recover), never on an edit.
|
||||
document_id: u64,
|
||||
/// How many times the document's body was replaced under the same [`Self::document_id`]
|
||||
/// (`document.setText`, version restore, compare, Undo/Redo). It is the body's IME
|
||||
/// replacement epoch: a composition must not survive the swap, even when the new body has
|
||||
@@ -479,22 +558,7 @@ pub struct Session {
|
||||
/// inherit the origin.
|
||||
ime_origin: bool,
|
||||
cache: LayoutCache,
|
||||
/// (revision, width, read height, view, read line spacing, layout, proofing, outline,
|
||||
/// (deletions left out, deletions marked Hidden, body deletions in balloons), numeral display, the word being typed).
|
||||
#[allow(clippy::type_complexity)]
|
||||
layout: Option<(
|
||||
u64,
|
||||
f32,
|
||||
f32,
|
||||
ViewMode,
|
||||
Option<u32>,
|
||||
Arc<DocLayout>,
|
||||
bool,
|
||||
Option<wordcraft_layout::outline::OutlineView>,
|
||||
(bool, wordcraft_layout::HideDeleted, bool),
|
||||
NumeralMode,
|
||||
Option<wordcraft_layout::TypingWord>,
|
||||
)>,
|
||||
layout: LayoutSlots,
|
||||
/// The word being typed, left without a spelling squiggle ([`TypedWord`]).
|
||||
typing: Option<TypedWord>,
|
||||
/// Picture edits: edited media key → original media key (Reset Picture).
|
||||
@@ -547,8 +611,12 @@ pub struct Session {
|
||||
/// Password, `file.encrypt`); `None` saves them unencrypted. Kept from a password-protected
|
||||
/// file that was opened, cleared when the document is replaced.
|
||||
pub password: Option<crate::io::Password>,
|
||||
/// The recovery worker every open document's copy is kept through (desktop hosts only).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) recovery: Option<crate::recovery::Recovery>,
|
||||
/// The active document's recovery copy.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) recovery_slot: crate::recovery::Slot,
|
||||
/// More pictures and shapes selected along with the one the selection holds (Shift+click),
|
||||
/// for Group. Cleared by any edit or selection change other than adding to it.
|
||||
pub also_selected: Vec<Pos>,
|
||||
@@ -564,6 +632,12 @@ pub struct Session {
|
||||
edit_marks: Vec<Pos>,
|
||||
/// How many Go Back presses since the last edit (which place comes next).
|
||||
go_back_step: usize,
|
||||
/// The open documents other than the active one, in no particular order.
|
||||
others: Vec<DocState>,
|
||||
/// Every open document's id, the active one's too, in the order they were opened.
|
||||
order: Vec<u64>,
|
||||
/// Untitled documents made so far, for the next one's name (`Document3`).
|
||||
untitled_count: u32,
|
||||
}
|
||||
|
||||
/// The number of edit locations Go Back cycles through (Word: four).
|
||||
@@ -723,6 +797,7 @@ fn typed_range(text: &str, off: usize) -> Option<(usize, usize)> {
|
||||
text.get(start..off)?.chars().any(char::is_alphanumeric).then_some((start, end))
|
||||
}
|
||||
|
||||
/// The name of `doc`'s bibliography style, APA when it names none we know.
|
||||
fn doc_bib_style(doc: &Document) -> String {
|
||||
doc.settings.bib_style.as_deref().and_then(crate::cmd::citations::find_style).map_or("APA", |st| st.name).to_string()
|
||||
}
|
||||
@@ -749,10 +824,14 @@ impl Session {
|
||||
|
||||
/// Save the custom dictionary through [`Session::user_dict_path`], if the host wired one up,
|
||||
/// with `removed` (words just taken out) left out of the merge with the file. A failed save
|
||||
/// keeps the words in memory and never fails the command (#40).
|
||||
/// keeps the words in memory and never fails the command (#40), as does a file outside the
|
||||
/// automation roots in force: it is read, then replaced beside itself (#3737).
|
||||
pub(crate) fn persist_user_dictionary(&self, removed: &[String]) {
|
||||
if let Some(path) = self.user_dict_path.as_deref()
|
||||
&& let Err(e) = wordcraft_proof::save_user_dictionary(path, removed)
|
||||
let Some(path) = self.user_dict_path.as_deref() else { return };
|
||||
let name = path.to_string_lossy();
|
||||
if let Err(e) = crate::file_access::check_read(&name)
|
||||
.and_then(|()| crate::file_access::check_write(&name))
|
||||
.and_then(|()| wordcraft_proof::save_user_dictionary(path, removed).map_err(|e| e.to_string()))
|
||||
{
|
||||
log::warn!("custom dictionary: save failed: {e}");
|
||||
}
|
||||
@@ -766,46 +845,94 @@ impl Session {
|
||||
|
||||
/// Initialise session defaults while sharing the immutable command registry with previews.
|
||||
fn with_registry(doc: Document, registry: Arc<Registry>) -> Self {
|
||||
let start = doc.start_of(StoryRef::Body);
|
||||
let bib_style = doc_bib_style(&doc);
|
||||
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,
|
||||
} = DocState::new(doc, 1, ViewState::default());
|
||||
Session {
|
||||
doc,
|
||||
sel: Selection::caret(start),
|
||||
view: ViewState::default(),
|
||||
pending: None,
|
||||
sel,
|
||||
view,
|
||||
pending,
|
||||
last_highlight: wordcraft_doc::props::Highlight::Yellow,
|
||||
path: None,
|
||||
file_name: None,
|
||||
path,
|
||||
file_name,
|
||||
user_dict_path: None,
|
||||
dirty: false,
|
||||
dirty,
|
||||
clipboard: None,
|
||||
pending_move: None,
|
||||
pending_move,
|
||||
clipboard_text: String::new(),
|
||||
clipboard_html: String::new(),
|
||||
clip_history: Default::default(),
|
||||
find: FindState::default(),
|
||||
goal_x: None,
|
||||
page_hint: 0,
|
||||
find,
|
||||
goal_x,
|
||||
page_hint,
|
||||
registry,
|
||||
author: "WordCraft User".into(),
|
||||
initials: String::new(),
|
||||
painter: None,
|
||||
status: String::new(),
|
||||
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,
|
||||
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,
|
||||
protect_facts: std::sync::Mutex::new(None),
|
||||
document_id: 1,
|
||||
ime_body_epoch: 0,
|
||||
ime_body_epoch,
|
||||
ime_state_revision: None,
|
||||
ime_state_seen: None,
|
||||
ime_edit_outcome: ImeEditOutcome::default(),
|
||||
@@ -813,39 +940,44 @@ impl Session {
|
||||
ime_state_exhausted: false,
|
||||
pending_ime_application_change: false,
|
||||
ime_origin: false,
|
||||
cache: LayoutCache::new(),
|
||||
layout: None,
|
||||
typing: None,
|
||||
originals: Default::default(),
|
||||
picture_looks: Default::default(),
|
||||
cache,
|
||||
layout,
|
||||
typing,
|
||||
originals,
|
||||
picture_looks,
|
||||
last_command: None,
|
||||
recording: None,
|
||||
macros: Default::default(),
|
||||
versions: Vec::new(),
|
||||
versions,
|
||||
building_blocks: Default::default(),
|
||||
autosave: true,
|
||||
autosave,
|
||||
bib_style,
|
||||
merge: Default::default(),
|
||||
merge,
|
||||
ui_requests: Vec::new(),
|
||||
math: None,
|
||||
math,
|
||||
math_latex: false,
|
||||
math_normal_text: false,
|
||||
also_selected: Vec::new(),
|
||||
also_selected,
|
||||
prefs: Prefs::default(),
|
||||
system_region: Default::default(),
|
||||
keymap: crate::KeyMap::default(),
|
||||
ribbon: crate::RibbonLayout::default(),
|
||||
editing_lang: None,
|
||||
read_aloud: Default::default(),
|
||||
password: None,
|
||||
password,
|
||||
windows: Default::default(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
recovery: None,
|
||||
column: None,
|
||||
column_mode: false,
|
||||
widened: None,
|
||||
edit_marks: Vec::new(),
|
||||
go_back_step: 0,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
recovery_slot,
|
||||
column,
|
||||
column_mode,
|
||||
widened,
|
||||
edit_marks,
|
||||
go_back_step,
|
||||
others: Vec::new(),
|
||||
order: vec![document_id],
|
||||
untitled_count: 1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -989,7 +1121,7 @@ impl Session {
|
||||
|
||||
/// The document's file name and full path for fields (`FILENAME`) and layout context.
|
||||
/// Returns `(filename, filepath)`. Unsaved documents with no explicit file name default
|
||||
/// consistently to `("Document1", "Document1")`. Document metadata title is never used
|
||||
/// consistently to their name, `("Document1", "Document1")` for the first. Document metadata title is never used
|
||||
/// as a filename fallback (#3449).
|
||||
pub fn resolved_filenames(&self) -> (String, String) {
|
||||
if let Some(path) = &self.path {
|
||||
@@ -999,7 +1131,8 @@ impl Session {
|
||||
} else if let Some(name) = &self.file_name {
|
||||
(name.clone(), name.clone())
|
||||
} else {
|
||||
("Document1".to_string(), "Document1".to_string())
|
||||
let name = untitled_name(self.untitled);
|
||||
(name.clone(), name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1024,19 +1157,30 @@ impl Session {
|
||||
let hide_deleted = !self.view.show_markup;
|
||||
let deleted = (hide_deleted, self.prefs.markup.hidden_deletions(), self.revisions_in_balloons());
|
||||
let outline = (mode == ViewMode::Outline).then(|| self.view.outline.view());
|
||||
if let Some((r, w, h, m, sp, l, pf, o, hd, n, t)) = &self.layout
|
||||
&& *r == self.rev
|
||||
&& (*w == width || mode == ViewMode::Print)
|
||||
&& *h == height
|
||||
&& *m == mode
|
||||
&& *sp == spacing
|
||||
&& *pf == self.view.proofing
|
||||
&& *n == self.view.numeral
|
||||
&& *hd == deleted
|
||||
&& *o == outline
|
||||
&& *t == typing
|
||||
let shown = (self.view.marks || self.prefs.marks.hidden, self.view.field_codes);
|
||||
let (rev, proofing, numeral) = (self.rev, self.view.proofing, self.view.numeral);
|
||||
let fits = |e: &Option<LayoutEntry>| {
|
||||
e.as_ref().is_some_and(|(r, w, h, m, sp, _, pf, o, hd, n, t, sh)| {
|
||||
*r == rev
|
||||
&& (*w == width || mode == ViewMode::Print)
|
||||
&& *h == height
|
||||
&& *m == mode
|
||||
&& *sp == spacing
|
||||
&& *pf == proofing
|
||||
&& *n == numeral
|
||||
&& *hd == deleted
|
||||
&& *o == outline
|
||||
&& *t == typing
|
||||
&& *sh == shown
|
||||
})
|
||||
};
|
||||
if !fits(&self.layout[0]) && fits(&self.layout[1]) {
|
||||
self.layout.swap(0, 1);
|
||||
}
|
||||
if fits(&self.layout[0])
|
||||
&& let Some(e) = &self.layout[0]
|
||||
{
|
||||
return l.clone();
|
||||
return e.5.clone();
|
||||
}
|
||||
let (fn_name, fn_path) = self.resolved_filenames();
|
||||
let (filename, filepath): (Option<Arc<str>>, Option<Arc<str>>) = (Some(Arc::from(fn_name.as_str())), Some(Arc::from(fn_path.as_str())));
|
||||
@@ -1045,14 +1189,14 @@ impl Session {
|
||||
web_width: width,
|
||||
read_height: height,
|
||||
read_line_spacing: spacing.map(f32::from_bits),
|
||||
show_hidden: self.view.marks || self.prefs.marks.hidden,
|
||||
show_hidden: shown.0,
|
||||
hide_deleted,
|
||||
hide_marked: deleted.1,
|
||||
deletions_in_balloons: deleted.2,
|
||||
proofing: self.view.proofing,
|
||||
numeral: self.view.numeral.resolve(SYSTEM_LOCALE.get().map(String::as_str)),
|
||||
comment_balloons: false,
|
||||
field_codes: self.view.field_codes,
|
||||
field_codes: shown.1,
|
||||
outline: outline.clone(),
|
||||
proof: wordcraft_layout::ProofOptions { grammar: self.prefs.mark_grammar, spelling: self.prefs.spelling() },
|
||||
typing_word: typing,
|
||||
@@ -1060,7 +1204,11 @@ impl Session {
|
||||
filepath: filepath.clone(),
|
||||
};
|
||||
let l = Arc::new(wordcraft_layout::layout(&self.doc, &mut self.cache, &opts));
|
||||
self.layout = Some((self.rev, width, height, mode, spacing, l.clone(), self.view.proofing, outline, deleted, self.view.numeral, typing));
|
||||
let [newest, _] = std::mem::take(&mut self.layout);
|
||||
self.layout = [
|
||||
Some((self.rev, width, height, mode, spacing, l.clone(), self.view.proofing, outline, deleted, self.view.numeral, typing, shown)),
|
||||
newest,
|
||||
];
|
||||
l
|
||||
}
|
||||
|
||||
@@ -1158,7 +1306,7 @@ impl Session {
|
||||
|
||||
/// Invalidate the cached layout (fonts changed etc.).
|
||||
pub fn relayout(&mut self) {
|
||||
self.layout = None;
|
||||
self.layout = Default::default();
|
||||
self.cache.clear();
|
||||
}
|
||||
|
||||
@@ -1182,6 +1330,10 @@ impl Session {
|
||||
if untouched && self.doc.plain_text(StoryRef::Body).trim().is_empty() && self.doc.paragraph_count() <= 1 {
|
||||
self.doc.styles.default_chr.lang = Some(self.editing_lang.clone().unwrap_or_else(|| "en-US".into()));
|
||||
self.rev = self.rev.wrapping_add(1);
|
||||
// Not an edit: the document is as untouched as it was.
|
||||
if self.made_rev == self.rev.wrapping_sub(1) {
|
||||
self.made_rev = self.rev;
|
||||
}
|
||||
self.relayout();
|
||||
}
|
||||
}
|
||||
@@ -1465,8 +1617,11 @@ impl Session {
|
||||
/// Replace the document (open/new).
|
||||
pub fn set_document(&mut self, doc: Document) {
|
||||
self.autocap = None;
|
||||
// Never reuse an id after wrapping: the IME context is keyed by it.
|
||||
self.document_id = self.document_id.saturating_add(1);
|
||||
let replaced = self.document_id;
|
||||
self.document_id = next_document_id();
|
||||
if let Some(at) = self.order.iter_mut().find(|d| **d == replaced) {
|
||||
*at = self.document_id;
|
||||
}
|
||||
self.note_body_replaced();
|
||||
self.bib_style = doc_bib_style(&doc);
|
||||
self.doc = doc;
|
||||
@@ -1487,10 +1642,16 @@ impl Session {
|
||||
self.hf_return = None;
|
||||
self.go_back_step = 0;
|
||||
self.view.outline.collapsed.clear();
|
||||
// A copy the replaced document kept stays in the recovery list.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
self.recovery_slot = Default::default();
|
||||
}
|
||||
self.reset_history();
|
||||
self.clear_versions();
|
||||
self.touch();
|
||||
self.dirty = false;
|
||||
self.made_rev = self.rev;
|
||||
self.relayout();
|
||||
}
|
||||
|
||||
@@ -1507,8 +1668,8 @@ impl Session {
|
||||
///
|
||||
/// `restore` is exact when the commands in between only add undo steps (every mutating
|
||||
/// command adds at most one; up to [`SNAPSHOT_HEAD`] may push old steps out of a full
|
||||
/// stack). Taking steps off the stack (`edit.undo`) or clearing it (`file.new`,
|
||||
/// `file.open`) in between is not covered.
|
||||
/// stack). Taking steps off the stack (`edit.undo`), clearing it (`file.open` into
|
||||
/// a blank document) or switching documents in between is not covered.
|
||||
///
|
||||
/// The destructuring lists every field, so a new field does not build until someone decides
|
||||
/// whether a snapshot must cover it.
|
||||
@@ -1568,6 +1729,13 @@ impl Session {
|
||||
merge: _,
|
||||
ui_requests: _,
|
||||
document_id: _,
|
||||
// The untitled name and when the document was made: not edits.
|
||||
untitled: _,
|
||||
made_rev: _,
|
||||
// The other open documents: a snapshot covers the active one.
|
||||
others: _,
|
||||
order: _,
|
||||
untitled_count: _,
|
||||
ime_body_epoch,
|
||||
// The IME revision and its caret token are process-global evidence, not document
|
||||
// state: a rollback must not reuse a revision.
|
||||
@@ -1595,6 +1763,8 @@ impl Session {
|
||||
password: _,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
recovery: _,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
recovery_slot: _,
|
||||
also_selected: _,
|
||||
// Column selection and a widened selection's ends, like `sel`'s shape: valid only while
|
||||
// `sel` matches them.
|
||||
@@ -1700,8 +1870,10 @@ impl Session {
|
||||
return;
|
||||
}
|
||||
self.rev = rev;
|
||||
if self.layout.as_ref().is_some_and(|l| l.0 != rev) {
|
||||
self.layout = None;
|
||||
for slot in &mut self.layout {
|
||||
if slot.as_ref().is_some_and(|l| l.0 != rev) {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
if self.find.highlights.as_ref().is_some_and(|(r, _)| *r != rev) {
|
||||
self.find.highlights = None;
|
||||
@@ -1820,6 +1992,9 @@ impl Session {
|
||||
/// control channel). Mutating commands are undoable; a failed command leaves the document as
|
||||
/// it was; a panic inside a command becomes an error.
|
||||
pub fn run(&mut self, id: &str, params: &Value) -> Result<Value, CmdError> {
|
||||
// The document the command starts in, and the first id a document it opens can have.
|
||||
let home = self.document_id;
|
||||
let mark = document_id_mark();
|
||||
// Every dispatch starts from an unclassified outcome. Early returns (unknown, disabled,
|
||||
// protected) leave it at the default.
|
||||
self.ime_edit_outcome = ImeEditOutcome::default();
|
||||
@@ -1879,7 +2054,7 @@ impl Session {
|
||||
// Commands may leave an equation (a whole-body replacement) even after they fail; put
|
||||
// the editing session back with the document.
|
||||
let math_before = spec.mutates.then(|| self.math.clone());
|
||||
let before_doc = if spec.mutates {
|
||||
let before_doc: Option<RunSnapshot> = if spec.mutates {
|
||||
Some((
|
||||
self.doc.clone(),
|
||||
self.sel.clone(),
|
||||
@@ -1963,6 +2138,9 @@ impl Session {
|
||||
Err(CmdError::Failed(format!("{id} panicked: {msg}")))
|
||||
}
|
||||
};
|
||||
if self.document_id != home || (result.is_err() && self.order.iter().any(|d| *d >= mark)) {
|
||||
return self.finish_elsewhere(home, mark, result, before_doc, blocks_before, default_language_before, pending_application_change_before);
|
||||
}
|
||||
// Restrict Editing: outside the exceptions nothing may change.
|
||||
if result.is_ok()
|
||||
&& guard
|
||||
@@ -2128,6 +2306,63 @@ impl Session {
|
||||
result
|
||||
}
|
||||
|
||||
/// The end of [`Session::run`] for a command that ended up in another document than it
|
||||
/// started in (it opened, made, switched to or closed one) or that failed after opening one.
|
||||
/// The checks that compare the document before and after the command don't apply.
|
||||
/// Succeeded: the first document loses the undo step made for the command when its text is
|
||||
/// as it was (opening a document is no edit to undo there). Failed: the documents the command opened close again and the first document is
|
||||
/// active and as it was.
|
||||
fn finish_elsewhere(
|
||||
&mut self,
|
||||
home: u64,
|
||||
mark: u64,
|
||||
result: Result<Value, CmdError>,
|
||||
before_doc: Option<RunSnapshot>,
|
||||
blocks_before: Option<std::collections::BTreeMap<String, Fragment>>,
|
||||
default_language_before: Option<String>,
|
||||
pending_application_change_before: bool,
|
||||
) -> Result<Value, CmdError> {
|
||||
// Another document is a state change the input method has to see, never an IME edit.
|
||||
self.ime_edit_outcome = ImeEditOutcome::default();
|
||||
match &result {
|
||||
Ok(_) => {
|
||||
self.put_back_parked(home, before_doc);
|
||||
self.clamp_selection();
|
||||
self.ime_state_seen = Some(self.current_ime_caret_state());
|
||||
self.reset_ime_state_revision();
|
||||
if !self.ime_origin {
|
||||
self.pending_ime_application_change = true;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
self.pending_ime_application_change = pending_application_change_before;
|
||||
self.autocap = None;
|
||||
self.prefs.default_language = default_language_before;
|
||||
if self.is_open(home) {
|
||||
let _ = self.switch_to(home);
|
||||
let opened: Vec<u64> = self.order.iter().copied().filter(|d| *d >= mark && *d != home).collect();
|
||||
for d in opened {
|
||||
let _ = self.close_document(d);
|
||||
}
|
||||
if let Some((d, s, h, r, t, ev, c)) = before_doc {
|
||||
self.doc = d;
|
||||
self.sel = s;
|
||||
self.history = h;
|
||||
self.redo = r;
|
||||
self.typing_open = t;
|
||||
self.undo_evicted = ev;
|
||||
self.column = c;
|
||||
}
|
||||
}
|
||||
if let Some(b) = blocks_before {
|
||||
self.building_blocks = b;
|
||||
}
|
||||
self.status = e.to_string();
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// The paragraph path the caret is in.
|
||||
pub fn caret_path(&self) -> &Path {
|
||||
&self.sel.focus.path
|
||||
|
||||
@@ -0,0 +1,659 @@
|
||||
//! 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: _,
|
||||
// What the drawn window knows of the others: its front end sets it as it draws.
|
||||
windows: _,
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -551,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 {
|
||||
@@ -564,6 +567,7 @@ impl Default for ReadAloud {
|
||||
backend: if off { Backend::Hold } else { Backend::System },
|
||||
skip_citations: true,
|
||||
open: false,
|
||||
document: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+126
-16
@@ -248,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.
|
||||
@@ -3954,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();
|
||||
@@ -4590,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()));
|
||||
@@ -4613,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");
|
||||
@@ -4771,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)]
|
||||
@@ -5037,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();
|
||||
@@ -5769,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);
|
||||
@@ -9367,23 +9477,20 @@ fn window_commands_need_the_desktop_and_name_a_window() {
|
||||
use crate::cmd::view::OtherWindow;
|
||||
let mut s = s();
|
||||
// Web, CLI, MCP: one window, so View › Window is disabled.
|
||||
for id in ["view.switchWindows", "view.arrangeAll", "view.sideBySide", "view.syncScroll"] {
|
||||
for id in ["view.arrangeAll", "view.sideBySide", "view.syncScroll"] {
|
||||
assert!(s.run(id, &json!({})).is_err(), "{id} without a window host");
|
||||
}
|
||||
s.windows.available = true;
|
||||
run(&mut s, "view.arrangeAll", json!({}));
|
||||
assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "arrange"})));
|
||||
assert!(s.run("view.switchWindows", &json!({})).is_err(), "no other window");
|
||||
assert!(s.run("view.sideBySide", &json!({})).is_err(), "no other window");
|
||||
s.windows.others = vec![OtherWindow { id: 7, title: "Notes".into() }, OtherWindow { id: 9, title: "Draft".into() }];
|
||||
let e = s.run("view.switchWindows", &json!({})).unwrap_err().to_string();
|
||||
let e = s.run("view.sideBySide", &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("7 (Notes)") && e.contains("9 (Draft)"), "several windows: say which: {e}");
|
||||
assert!(s.run("view.switchWindows", &json!({"window": 8})).is_err(), "an unknown window");
|
||||
assert!(s.run("view.switchWindows", &json!({"window": "9"})).is_err());
|
||||
run(&mut s, "view.switchWindows", json!({"window": 9}));
|
||||
assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "focus", "id": 9})));
|
||||
assert!(s.run("view.sideBySide", &json!({"window": 8})).is_err(), "an unknown window");
|
||||
assert!(s.run("view.sideBySide", &json!({"window": "9"})).is_err());
|
||||
// Side by Side pairs with one window and scrolls together until turned off.
|
||||
assert!(s.run("view.syncScroll", &json!({})).is_err(), "only while Side by Side is on");
|
||||
assert!(s.run("view.sideBySide", &json!({})).is_err(), "two others: which one?");
|
||||
let r = run(&mut s, "view.sideBySide", json!({"window": 7}));
|
||||
assert_eq!((r["value"].as_bool(), s.windows.side_by_side, s.windows.sync_scroll), (Some(true), Some(7), true));
|
||||
assert_eq!(s.ui_requests.pop(), Some(json!({"windows": "sideBySide", "with": 7})));
|
||||
@@ -9789,9 +9896,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."));
|
||||
@@ -9809,7 +9917,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")));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ kurbo = { workspace = true }
|
||||
skrifa = { workspace = true }
|
||||
harfrust = { workspace = true }
|
||||
wuff = { workspace = true }
|
||||
brotli-decompressor = { workspace = true }
|
||||
unicode-bidi = { workspace = true }
|
||||
unicode-script = { workspace = true }
|
||||
log = { workspace = true }
|
||||
+101
-13
@@ -18,6 +18,9 @@ use crate::FontFace;
|
||||
|
||||
/// Upper bound on the ops one colour glyph may expand to.
|
||||
pub const MAX_OPS: usize = 20_000;
|
||||
/// Upper bound on the gradient stops and clip-path elements one colour glyph may copy in total: a
|
||||
/// paint graph can reference one long stop list or outline from every op.
|
||||
pub const MAX_PAYLOAD: usize = 1 << 16;
|
||||
/// Upper bound on a bitmap glyph's width and height in pixels.
|
||||
pub const MAX_BITMAP_SIDE: u32 = 2048;
|
||||
|
||||
@@ -124,10 +127,10 @@ impl FontFace {
|
||||
if let Some(cg) = ColorGlyphCollection::new(&f).get(glyph) {
|
||||
let palette: Vec<[u8; 4]> =
|
||||
ColorPalettes::new(&f).get(0).map(|p| p.colors().iter().map(|c| [c.red, c.green, c.blue, c.alpha]).collect()).unwrap_or_default();
|
||||
let mut rec = Recorder::new(&palette, |g: GlyphId| {
|
||||
let mut p = self.glyph_outline(g.to_u32());
|
||||
let mut rec = Recorder::new(&palette, |g: GlyphId, room: usize| {
|
||||
let mut p = self.glyph_outline_capped(g.to_u32(), room)?;
|
||||
p.apply_affine(Affine::FLIP_Y);
|
||||
p
|
||||
Some(p)
|
||||
});
|
||||
if cg.paint(self.location(), &mut rec).is_ok()
|
||||
&& let Some(ops) = rec.finish()
|
||||
@@ -175,20 +178,34 @@ pub fn bitmap_placement(bg: &skrifa::bitmap::BitmapGlyph<'_>, upem: f64) -> Opti
|
||||
finite.then_some(ColorBitmap { data, width: w, height: h, rect, ppem: bg.ppem_y })
|
||||
}
|
||||
|
||||
/// Approximate heap bytes held by `ops`, counting each op's copied stops and path elements.
|
||||
pub fn ops_bytes(ops: &[ColorOp]) -> usize {
|
||||
let payload = |op: &ColorOp| match op {
|
||||
ColorOp::PushClip(p) => p.elements().len().saturating_mul(std::mem::size_of::<kurbo::PathEl>()),
|
||||
ColorOp::Fill(ColorPaint::Linear { stops, .. } | ColorPaint::Radial { stops, .. } | ColorPaint::Sweep { stops, .. }) => {
|
||||
stops.len().saturating_mul(std::mem::size_of::<Stop>())
|
||||
}
|
||||
_ => 0,
|
||||
};
|
||||
ops.iter().fold(ops.len().saturating_mul(std::mem::size_of::<ColorOp>()), |n, op| n.saturating_add(payload(op)))
|
||||
}
|
||||
|
||||
/// A [`ColorPainter`] that records the paint calls as [`ColorOp`]s, resolving palette colours and
|
||||
/// clip glyphs. Stops recording (and [`Recorder::finish`] gives None) past [`MAX_OPS`].
|
||||
pub struct Recorder<'a, F: Fn(GlyphId) -> BezPath> {
|
||||
/// clip glyphs. Stops recording (and [`Recorder::finish`] gives None) past [`MAX_OPS`] or
|
||||
/// [`MAX_PAYLOAD`].
|
||||
pub struct Recorder<'a, F: Fn(GlyphId, usize) -> Option<BezPath>> {
|
||||
palette: &'a [[u8; 4]],
|
||||
outline: F,
|
||||
ops: Vec<ColorOp>,
|
||||
payload: usize,
|
||||
overflow: bool,
|
||||
}
|
||||
|
||||
impl<'a, F: Fn(GlyphId) -> BezPath> Recorder<'a, F> {
|
||||
impl<'a, F: Fn(GlyphId, usize) -> Option<BezPath>> Recorder<'a, F> {
|
||||
/// A recorder with palette `palette` (straight RGBA) and clip outlines (font units, y up) from
|
||||
/// `outline`.
|
||||
/// `outline`, which is given the path elements left in the budget and gives None past them.
|
||||
pub fn new(palette: &'a [[u8; 4]], outline: F) -> Self {
|
||||
Recorder { palette, outline, ops: vec![], overflow: false }
|
||||
Recorder { palette, outline, ops: vec![], payload: 0, overflow: false }
|
||||
}
|
||||
|
||||
/// The recorded ops, None when the glyph was too big or its pushes and pops don't balance.
|
||||
@@ -218,6 +235,15 @@ impl<'a, F: Fn(GlyphId) -> BezPath> Recorder<'a, F> {
|
||||
self.ops.push(op);
|
||||
}
|
||||
|
||||
/// Charge `n` stops or path elements to the glyph's payload; false (and overflow) past the budget.
|
||||
fn charge(&mut self, n: usize) -> bool {
|
||||
self.payload = self.payload.saturating_add(n);
|
||||
if self.payload > MAX_PAYLOAD {
|
||||
self.overflow = true;
|
||||
}
|
||||
!self.overflow
|
||||
}
|
||||
|
||||
fn color(&self, palette_index: u16, alpha: f32) -> GlyphColor {
|
||||
let alpha = if alpha.is_finite() { alpha.clamp(0.0, 1.0) } else { 1.0 };
|
||||
if palette_index == 0xFFFF {
|
||||
@@ -241,7 +267,7 @@ fn pt(p: skrifa::raw::types::Point<f32>) -> Point {
|
||||
Point::new(p.x as f64, p.y as f64)
|
||||
}
|
||||
|
||||
impl<F: Fn(GlyphId) -> BezPath> ColorPainter for Recorder<'_, F> {
|
||||
impl<F: Fn(GlyphId, usize) -> Option<BezPath>> ColorPainter for Recorder<'_, F> {
|
||||
fn push_transform(&mut self, t: Transform) {
|
||||
self.push(ColorOp::PushTransform(Affine::new([t.xx, t.yx, t.xy, t.yy, t.dx, t.dy].map(|v| v as f64))));
|
||||
}
|
||||
@@ -249,8 +275,17 @@ impl<F: Fn(GlyphId) -> BezPath> ColorPainter for Recorder<'_, F> {
|
||||
self.push(ColorOp::PopTransform);
|
||||
}
|
||||
fn push_clip_glyph(&mut self, glyph_id: GlyphId) {
|
||||
let p = (self.outline)(glyph_id);
|
||||
self.push(ColorOp::PushClip(p));
|
||||
if self.overflow {
|
||||
return;
|
||||
}
|
||||
// Built within what is left of the budget: a huge outline is refused while it is drawn.
|
||||
let Some(p) = (self.outline)(glyph_id, MAX_PAYLOAD.saturating_sub(self.payload)) else {
|
||||
self.overflow = true;
|
||||
return;
|
||||
};
|
||||
if self.charge(p.elements().len()) {
|
||||
self.push(ColorOp::PushClip(p));
|
||||
}
|
||||
}
|
||||
fn push_clip_box(&mut self, b: BoundingBox<f32>) {
|
||||
let r = Rect::new(b.x_min as f64, b.y_min as f64, b.x_max as f64, b.y_max as f64);
|
||||
@@ -260,6 +295,15 @@ impl<F: Fn(GlyphId) -> BezPath> ColorPainter for Recorder<'_, F> {
|
||||
self.push(ColorOp::PopClip);
|
||||
}
|
||||
fn fill(&mut self, brush: Brush<'_>) {
|
||||
let stops = match &brush {
|
||||
Brush::Solid { .. } => 0,
|
||||
Brush::LinearGradient { color_stops, .. } | Brush::RadialGradient { color_stops, .. } | Brush::SweepGradient { color_stops, .. } => {
|
||||
color_stops.len()
|
||||
}
|
||||
};
|
||||
if !self.charge(stops) {
|
||||
return;
|
||||
}
|
||||
let paint = match brush {
|
||||
Brush::Solid { palette_index, alpha } => ColorPaint::Solid(self.color(palette_index, alpha)),
|
||||
Brush::LinearGradient { p0, p1, color_stops, extend } => {
|
||||
@@ -286,8 +330,8 @@ impl<F: Fn(GlyphId) -> BezPath> ColorPainter for Recorder<'_, F> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn square(_: GlyphId) -> BezPath {
|
||||
kurbo::Shape::to_path(&Rect::new(0.0, 0.0, 10.0, 10.0), 0.1)
|
||||
fn square(_: GlyphId, room: usize) -> Option<BezPath> {
|
||||
Some(kurbo::Shape::to_path(&Rect::new(0.0, 0.0, 10.0, 10.0), 0.1)).filter(|p| p.elements().len() <= room)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -337,6 +381,50 @@ mod tests {
|
||||
assert!(r.finish().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradient_stops_and_clip_paths_share_a_payload_budget() {
|
||||
// A few hundred fills reusing one long stop list would copy it per fill, far under MAX_OPS.
|
||||
let stops = vec![ColorStop { offset: 0.5, palette_index: 0, alpha: 1.0 }; 4096];
|
||||
let fills = MAX_PAYLOAD / stops.len() + 1;
|
||||
assert!(fills < MAX_OPS);
|
||||
let outlines = std::cell::Cell::new(0);
|
||||
let count = |g, room| {
|
||||
outlines.set(outlines.get() + 1);
|
||||
square(g, room)
|
||||
};
|
||||
let mut r = Recorder::new(&[], count);
|
||||
for _ in 0..fills {
|
||||
r.fill(Brush::LinearGradient { p0: Default::default(), p1: Default::default(), color_stops: &stops, extend: Extend::Pad });
|
||||
}
|
||||
assert!(r.ops.len() < fills, "stops past the budget are not copied");
|
||||
r.push_clip_glyph(GlyphId::new(1));
|
||||
r.pop_clip();
|
||||
assert_eq!(outlines.get(), 0, "no clip outline is built once the budget is spent");
|
||||
assert!(r.finish().is_none());
|
||||
// Within budget nothing changes, and the byte estimate counts the copied stops.
|
||||
let mut r = Recorder::new(&[], square);
|
||||
r.fill(Brush::LinearGradient { p0: Default::default(), p1: Default::default(), color_stops: &stops, extend: Extend::Pad });
|
||||
let ops = r.finish().unwrap();
|
||||
assert!(ops_bytes(&ops) >= stops.len() * std::mem::size_of::<Stop>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clip_outline_is_drawn_within_the_payload_left() {
|
||||
// #3716 review: the outline is told the room left, so an oversized one is never built whole.
|
||||
let stops = vec![ColorStop { offset: 0.5, palette_index: 0, alpha: 1.0 }; MAX_PAYLOAD - 3];
|
||||
let rooms = std::cell::RefCell::new(vec![]);
|
||||
let seen = |g, room| {
|
||||
rooms.borrow_mut().push(room);
|
||||
square(g, room)
|
||||
};
|
||||
let mut r = Recorder::new(&[], seen);
|
||||
r.fill(Brush::LinearGradient { p0: Default::default(), p1: Default::default(), color_stops: &stops, extend: Extend::Pad });
|
||||
r.push_clip_glyph(GlyphId::new(1));
|
||||
r.pop_clip();
|
||||
assert_eq!(*rooms.borrow(), [3]);
|
||||
assert!(r.finish().is_none(), "a square needs more than the 3 elements left");
|
||||
}
|
||||
|
||||
fn bitmap(origin: skrifa::bitmap::Origin, data: skrifa::bitmap::BitmapData<'_>) -> skrifa::bitmap::BitmapGlyph<'_> {
|
||||
skrifa::bitmap::BitmapGlyph {
|
||||
data,
|
||||
|
||||
+342
-29
@@ -7,7 +7,7 @@ use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
use kurbo::BezPath;
|
||||
use skrifa::instance::{Location, LocationRef, Size};
|
||||
use skrifa::outline::{DrawSettings, Engine, HintingInstance, HintingOptions, OutlinePen, Target};
|
||||
use skrifa::outline::{DrawSettings, Engine, Hinting, HintingInstance, HintingOptions, OutlinePen, Target};
|
||||
use skrifa::raw::FileRef;
|
||||
use skrifa::string::StringId;
|
||||
use skrifa::{GlyphId, MetadataProvider};
|
||||
@@ -88,8 +88,47 @@ pub struct FontFace {
|
||||
bmp: std::sync::OnceLock<Box<[u64]>>,
|
||||
// Some legacy CJK fonts assemble their contours with TrueType instructions.
|
||||
outline_hinter: std::sync::OnceLock<Option<HintingInstance>>,
|
||||
/// Hinted draws so far, those that ran too long, and whether that turned hinting off
|
||||
/// ([`FontFace::count_hinted`]).
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
hint_draws: std::sync::atomic::AtomicU32,
|
||||
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
|
||||
hint_slow: std::sync::atomic::AtomicU32,
|
||||
hinting_off: std::sync::atomic::AtomicBool,
|
||||
/// A hinted draw slower than this is a strike ([`HINT_STRIKE`]; tests shorten it).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
hint_strike: std::time::Duration,
|
||||
}
|
||||
|
||||
/// Limits on what a face's TrueType instructions may run over (#3692). skrifa's interpreter budget
|
||||
/// counts instructions, not the points each one moves (SHZ, IUP… walk a whole zone), so a font with
|
||||
/// huge zones turns a short program into billions of point moves. Real fonts stay far below: a
|
||||
/// twilight zone of tens to hundreds of points, a stack of up to ~2,000 values, a few thousand
|
||||
/// points a glyph. Past them, glyphs are drawn unhinted.
|
||||
const MAX_HINT_TWILIGHT: u16 = 1024;
|
||||
const MAX_HINT_STACK: u16 = 4096;
|
||||
/// skrifa's buffer for one hinted glyph (points, stack, twilight zone, CVT and storage).
|
||||
const MAX_HINT_GLYPH_BYTES: usize = 256 * 1024;
|
||||
/// A hinted glyph slower than this is a strike against its face. A face under the limits above can
|
||||
/// still take seconds for one glyph, so [`MIN_HINT_STRIKES`] strikes that are also at least one
|
||||
/// in [`HINT_SLOW_RATIO`] of its hinted draws turn hinting off: a face that is slow throughout
|
||||
/// stops after a few glyphs, while the odd draw slowed by a busy machine leaves a face hinted.
|
||||
///
|
||||
/// Wall-clock, not interpreter work: skrifa's own budget (a million instructions a program, not
|
||||
/// settable) counts instructions, not the points each one moves, and it exposes no count of the
|
||||
/// work done, so time is the only measure there is (#3827). Whether such a face stays hinted can
|
||||
/// therefore depend on the machine, by design: only faces that need the interpreter to assemble
|
||||
/// their contours (legacy CJK fonts such as DFKai-SB) are hinted at all, and those typically hint
|
||||
/// a glyph in microseconds, so 50 ms is far above an honest face on a loaded machine; a face
|
||||
/// that trips it is drawn unhinted (its outlines possibly misassembled) rather than freezing the
|
||||
/// app. Native only (no clock on wasm).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const HINT_STRIKE: std::time::Duration = std::time::Duration::from_millis(50);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MIN_HINT_STRIKES: u32 = 3;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const HINT_SLOW_RATIO: u32 = 16;
|
||||
|
||||
/// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of
|
||||
/// the process; unlike cloning an `Arc`, copying it touches no shared reference count (which made
|
||||
/// parallel composition scale negatively).
|
||||
@@ -174,18 +213,74 @@ impl FontFace {
|
||||
pub fn id(&self) -> u32 {
|
||||
self.id
|
||||
}
|
||||
/// Counts a hinted draw, `slow` when it ran past [`HINT_STRIKE`]; true when it is the one that
|
||||
/// turns hinting off.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn count_hinted(&self, slow: bool) -> bool {
|
||||
let draws = self.hint_draws.fetch_add(1, Ordering::Relaxed).saturating_add(1);
|
||||
if !slow {
|
||||
return false;
|
||||
}
|
||||
let slow = self.hint_slow.fetch_add(1, Ordering::Relaxed).saturating_add(1);
|
||||
slow >= MIN_HINT_STRIKES && slow.saturating_mul(HINT_SLOW_RATIO) >= draws && !self.hinting_off.swap(true, Ordering::Relaxed)
|
||||
}
|
||||
/// Glyph `gid`'s outline in font units, y-down (flipped), uncached: for faces the database
|
||||
/// doesn't keep (font list previews); others go through [`FontDb::outline`]'s cache.
|
||||
pub fn glyph_outline(&self, gid: u32) -> BezPath {
|
||||
let mut pen = FlipPen(BezPath::new());
|
||||
self.draw_outline(gid, usize::MAX).path
|
||||
}
|
||||
/// [`FontFace::glyph_outline`], or None when the outline has more than `max_elements` path
|
||||
/// elements: drawing stops copying them at the limit, so a huge glyph is never built whole.
|
||||
pub fn glyph_outline_capped(&self, gid: u32, max_elements: usize) -> Option<BezPath> {
|
||||
let pen = self.draw_outline(gid, max_elements);
|
||||
(!pen.over).then_some(pen.path)
|
||||
}
|
||||
fn draw_outline(&self, gid: u32, max_elements: usize) -> FlipPen {
|
||||
let mut pen = FlipPen { path: BezPath::new(), room: max_elements, over: false, hinted: false };
|
||||
if let Some(f) = self.skrifa()
|
||||
&& let Some(g) = f.outline_glyphs().get(GlyphId::new(gid))
|
||||
{
|
||||
let hinter = self.outline_hinter.get_or_init(|| {
|
||||
let hinter =
|
||||
self.hinter().filter(|_| !self.hinting_off.load(Ordering::Relaxed) && g.draw_memory_size(Hinting::Embedded) <= MAX_HINT_GLYPH_BYTES);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let started = std::time::Instant::now();
|
||||
let settings = hinter.map_or_else(|| DrawSettings::unhinted(Size::unscaled(), self.location()), |h| DrawSettings::hinted(h, false));
|
||||
pen.hinted = hinter.is_some();
|
||||
let mut drawn = g.draw(settings, &mut pen);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if hinter.is_some() && self.count_hinted(started.elapsed() > self.hint_strike) {
|
||||
log::warn!("Hinting glyph {gid} in {:?} took {:?}: drawing the face unhinted", self.family, started.elapsed());
|
||||
}
|
||||
// A draw that was hinted when hinting went off (by itself, or by another thread's draw
|
||||
// meanwhile) is the kind that was slow or misassembled: draw it again unhinted, so
|
||||
// the outline cache never keeps it (#3692).
|
||||
if pen.hinted && self.hinting_off.load(Ordering::Relaxed) {
|
||||
pen = FlipPen { path: BezPath::new(), room: max_elements, over: false, hinted: false };
|
||||
drawn = g.draw(DrawSettings::unhinted(Size::unscaled(), self.location()), &mut pen);
|
||||
}
|
||||
if let Err(e) = drawn {
|
||||
log::warn!("Cannot draw glyph {gid} in {:?}: {e}", self.family);
|
||||
pen.path = BezPath::new();
|
||||
}
|
||||
}
|
||||
pen
|
||||
}
|
||||
/// The face's TrueType interpreter, for the fonts that need one to assemble their contours and
|
||||
/// whose zones are within the limits ([`MAX_HINT_TWILIGHT`]).
|
||||
fn hinter(&self) -> Option<&HintingInstance> {
|
||||
self.outline_hinter
|
||||
.get_or_init(|| {
|
||||
use skrifa::raw::TableProvider;
|
||||
let f = self.skrifa()?;
|
||||
let outlines = f.outline_glyphs();
|
||||
if !outlines.require_interpreter() {
|
||||
return None;
|
||||
}
|
||||
let zones = f.maxp().ok().map(|m| (m.max_twilight_points().unwrap_or(0), m.max_stack_elements().unwrap_or(0)));
|
||||
if !zones.is_some_and(|(twilight, stack)| twilight <= MAX_HINT_TWILIGHT && stack <= MAX_HINT_STACK) {
|
||||
log::warn!("{:?} asks for hinting zones of {zones:?}: drawing it unhinted", self.family);
|
||||
return None;
|
||||
}
|
||||
// At UPEM pixels, hinted coordinates remain in font units. This
|
||||
// executes contour assembly without tying the cache to view zoom.
|
||||
HintingInstance::new(
|
||||
@@ -194,17 +289,10 @@ impl FontFace {
|
||||
self.location(),
|
||||
HintingOptions { engine: Engine::Interpreter, target: Target::Mono },
|
||||
)
|
||||
.map_err(|e| log::warn!("Cannot initialize outline interpreter for {}: {e}", self.family))
|
||||
.map_err(|e| log::warn!("Cannot initialize outline interpreter for {:?}: {e}", self.family))
|
||||
.ok()
|
||||
});
|
||||
let settings =
|
||||
hinter.as_ref().map_or_else(|| DrawSettings::unhinted(Size::unscaled(), self.location()), |h| DrawSettings::hinted(h, false));
|
||||
if let Err(e) = g.draw(settings, &mut pen) {
|
||||
log::warn!("Cannot draw glyph {gid} in {}: {e}", self.family);
|
||||
return BezPath::new();
|
||||
}
|
||||
}
|
||||
pen.0
|
||||
})
|
||||
.as_ref()
|
||||
}
|
||||
/// Is `family` this face's typographic, legacy, full or PostScript name?
|
||||
pub fn named(&self, family: &str) -> bool {
|
||||
@@ -825,7 +913,8 @@ fn font_faces(f: &skrifa::FontRef<'_>, index: u32) -> Vec<Found> {
|
||||
let east_asian = east_asian_font(f);
|
||||
let axes = f.axes();
|
||||
let mut named: Vec<Found> = Vec::new();
|
||||
for ni in f.named_instances().iter() {
|
||||
// At most one more than a file may add, so a caller can tell there are more (#3716).
|
||||
for ni in f.named_instances().iter().take(MAX_FILE_FACES + 1) {
|
||||
let Some(style) = name(f, &[ni.subfamily_name_id()]) else { continue };
|
||||
if named.iter().any(|n| n.style.eq_ignore_ascii_case(&style)) {
|
||||
continue;
|
||||
@@ -854,15 +943,25 @@ fn font_faces(f: &skrifa::FontRef<'_>, index: u32) -> Vec<Found> {
|
||||
}]
|
||||
}
|
||||
|
||||
/// Parse every face in `data` (a font file or collection); a variable font yields one face per
|
||||
/// named instance.
|
||||
fn enumerate_faces(data: &[u8]) -> Vec<Found> {
|
||||
/// Most faces one font file adds (collection members, variable fonts' named instances).
|
||||
const MAX_FILE_FACES: usize = 256;
|
||||
|
||||
/// Parse the faces in `data` (a font file or collection), at most `limit` of them; a variable
|
||||
/// font yields one face per named instance. A face with the family and style of an earlier one
|
||||
/// is left out (a collection can list one font many times, #3716).
|
||||
fn enumerate_faces(data: &[u8], limit: usize) -> Vec<Found> {
|
||||
let count = match FileRef::new(data) {
|
||||
Ok(FileRef::Font(_)) => 1,
|
||||
Ok(FileRef::Collection(c)) => c.len(),
|
||||
Ok(FileRef::Collection(c)) => c.len().min(u32::try_from(limit).unwrap_or(u32::MAX)),
|
||||
Err(_) => 0,
|
||||
};
|
||||
(0..count).filter_map(|i| skrifa::FontRef::from_index(data, i).ok().map(|f| font_faces(&f, i))).flatten().collect()
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
(0..count)
|
||||
.filter_map(|i| skrifa::FontRef::from_index(data, i).ok().map(|f| font_faces(&f, i)))
|
||||
.flatten()
|
||||
.filter(|f| seen.insert((f.family.to_ascii_lowercase(), f.style.to_ascii_lowercase())))
|
||||
.take(limit)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The compiled-in Source Sans 3 Regular, for a database that somehow has no faces at all.
|
||||
@@ -924,6 +1023,11 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, legacy
|
||||
index,
|
||||
bmp: std::sync::OnceLock::new(),
|
||||
outline_hinter: std::sync::OnceLock::new(),
|
||||
hint_draws: Default::default(),
|
||||
hint_slow: Default::default(),
|
||||
hinting_off: Default::default(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
hint_strike: HINT_STRIKE,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -986,7 +1090,7 @@ impl FontDb {
|
||||
let others = crate::CRAFT_FONTS.iter().filter(|f| !f.scripts.contains(&"Jpan"));
|
||||
let craft = crate::japanese_document_fonts().into_iter().chain(others).map(|f| f.bytes);
|
||||
for data in BUNDLED.iter().copied().chain(craft) {
|
||||
for f in enumerate_faces(data) {
|
||||
for f in enumerate_faces(data, MAX_FILE_FACES) {
|
||||
if let Some(mut face) = make_face(FontBytes::Static(data), f.index, f.family, f.style, f.legacy, f.coords) {
|
||||
face.local_names = f.local;
|
||||
faces.push(Arc::new(face));
|
||||
@@ -1173,7 +1277,7 @@ impl FontDb {
|
||||
for (bytes, name) in files {
|
||||
let data = Arc::new(bytes);
|
||||
let name: Option<String> = name.map(|n| n.trim().chars().take(MAX_FAMILY_NAME).collect()).filter(|n: &String| !n.is_empty());
|
||||
for f in enumerate_faces(&data) {
|
||||
for f in enumerate_faces(&data, MAX_FILE_FACES) {
|
||||
if let Some(mut face) = make_face(FontBytes::Owned(data.clone()), f.index, f.family, f.style, f.legacy, f.coords) {
|
||||
face.local_names = f.local;
|
||||
if let Some(n) = name.as_deref().filter(|n| !face.named(n) && !face.local_names.iter().any(|l| l.name.eq_ignore_ascii_case(n))) {
|
||||
@@ -1185,9 +1289,12 @@ impl FontDb {
|
||||
}
|
||||
}
|
||||
let mut faces = self.faces.write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
// Indexed: a document's fonts may add hundreds of faces to thousands (#3716).
|
||||
let key = |f: &FontFace| (f.family.to_ascii_lowercase(), f.style.to_ascii_lowercase());
|
||||
let mut present: std::collections::HashSet<_> = faces.iter().map(|g| key(g)).collect();
|
||||
let mut added = 0;
|
||||
for f in new {
|
||||
if !faces.iter().any(|g| g.family.eq_ignore_ascii_case(&f.family) && g.style.eq_ignore_ascii_case(&f.style)) {
|
||||
if present.insert(key(&f)) {
|
||||
faces.push(f);
|
||||
added += 1;
|
||||
}
|
||||
@@ -1387,7 +1494,9 @@ impl FontDb {
|
||||
(best.path.clone(), best.family.clone(), best.style.clone())
|
||||
};
|
||||
let data = Arc::new(read_fallback_file(&path)?);
|
||||
let found = enumerate_faces(&data).into_iter().find(|f| f.family.eq_ignore_ascii_case(&fam) && f.style.eq_ignore_ascii_case(&style))?;
|
||||
let found = enumerate_faces(&data, MAX_FILE_FACES)
|
||||
.into_iter()
|
||||
.find(|f| f.family.eq_ignore_ascii_case(&fam) && f.style.eq_ignore_ascii_case(&style))?;
|
||||
let mut face = make_face(FontBytes::Owned(data), found.index, found.family, found.style, found.legacy, found.coords)?;
|
||||
face.local_names = found.local;
|
||||
Some(Arc::new(face))
|
||||
@@ -1894,23 +2003,57 @@ impl FontDb {
|
||||
}
|
||||
}
|
||||
|
||||
struct FlipPen(BezPath);
|
||||
/// Collects an outline flipped to y-down, keeping at most `room` path elements (`over` once it
|
||||
/// had to drop one).
|
||||
struct FlipPen {
|
||||
path: BezPath,
|
||||
room: usize,
|
||||
over: bool,
|
||||
/// Drawn with the face's TrueType interpreter.
|
||||
hinted: bool,
|
||||
}
|
||||
|
||||
impl FlipPen {
|
||||
/// Take one element's room; false (and `over`) when there is none left.
|
||||
fn take(&mut self) -> bool {
|
||||
match self.room.checked_sub(1) {
|
||||
Some(room) if !self.over => {
|
||||
self.room = room;
|
||||
true
|
||||
}
|
||||
_ => {
|
||||
self.over = true;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OutlinePen for FlipPen {
|
||||
fn move_to(&mut self, x: f32, y: f32) {
|
||||
self.0.move_to((x as f64, -y as f64));
|
||||
if self.take() {
|
||||
self.path.move_to((x as f64, -y as f64));
|
||||
}
|
||||
}
|
||||
fn line_to(&mut self, x: f32, y: f32) {
|
||||
self.0.line_to((x as f64, -y as f64));
|
||||
if self.take() {
|
||||
self.path.line_to((x as f64, -y as f64));
|
||||
}
|
||||
}
|
||||
fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
|
||||
self.0.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64));
|
||||
if self.take() {
|
||||
self.path.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64));
|
||||
}
|
||||
}
|
||||
fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
|
||||
self.0.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64));
|
||||
if self.take() {
|
||||
self.path.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64));
|
||||
}
|
||||
}
|
||||
fn close(&mut self) {
|
||||
self.0.close_path();
|
||||
if self.take() {
|
||||
self.path.close_path();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1921,6 +2064,24 @@ mod tests_sysfonts;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn occasional_slow_hinted_glyphs_do_not_turn_hinting_off() {
|
||||
let off = |pattern: &[bool]| {
|
||||
let face =
|
||||
super::make_face(super::FontBytes::Static(super::BUNDLED[0]), 0, "T".into(), "Regular".into(), "T".into(), Vec::new()).unwrap();
|
||||
pattern.iter().position(|&slow| face.count_hinted(slow))
|
||||
};
|
||||
// A face slow throughout stops at the third strike.
|
||||
assert_eq!(off(&[true; 5]), Some(2));
|
||||
// Three draws slowed by a busy machine among many: hinting stays on.
|
||||
let mut busy = vec![false; 100];
|
||||
busy.extend([true; 3]);
|
||||
assert_eq!(off(&busy), None);
|
||||
// Slow glyphs spread among fast ones still stop it once they are one in 16.
|
||||
let spread: Vec<bool> = (0..64).map(|i| i % 8 == 7).collect();
|
||||
assert_eq!(off(&spread), Some(23));
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "requires a locally licensed kaiu.ttf; set WORDCRAFT_TEST_KAI_FONT"]
|
||||
fn kai_contours_include_complete_character_body() {
|
||||
@@ -1953,6 +2114,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_capped_outline_is_refused_while_it_is_drawn() {
|
||||
// #3716 review: a colour glyph's clip outline stops growing at the payload left.
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
db.set_system_fallback(false);
|
||||
let face = db.face(FALLBACK_FAMILY, "Regular");
|
||||
let gid = face.glyph_for('g');
|
||||
let whole = face.glyph_outline(gid);
|
||||
let n = whole.elements().len();
|
||||
assert!(n > 4, "{n}");
|
||||
assert_eq!(face.glyph_outline_capped(gid, n).as_ref().map(BezPath::elements), Some(whole.elements()));
|
||||
assert!(face.glyph_outline_capped(gid, n - 1).is_none());
|
||||
assert!(face.glyph_outline_capped(gid, 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_fallback_is_cached_per_char_set() {
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
@@ -2127,4 +2303,141 @@ mod tests {
|
||||
assert!(!db.has_family("NotAFont-Bold"));
|
||||
assert_eq!(db.face("Source Serif 4", "Regular").style, "Regular");
|
||||
}
|
||||
|
||||
/// A variable font holding just `name` and `fvar` tables: family "Var Test", one `wght` axis
|
||||
/// and `instances` named instances, each with its own style name.
|
||||
fn variable_font(instances: u16) -> Vec<u8> {
|
||||
let mut names: Vec<(u16, String)> = vec![(1, "Var Test".into()), (2, "Regular".into())];
|
||||
names.extend((0..instances).map(|i| (256 + i, format!("Style {i}"))));
|
||||
let mut name = vec![0, 0];
|
||||
name.extend_from_slice(&(names.len() as u16).to_be_bytes());
|
||||
name.extend_from_slice(&(6 + 12 * names.len() as u16).to_be_bytes());
|
||||
let mut strings = Vec::new();
|
||||
for (id, text) in &names {
|
||||
let utf16: Vec<u8> = text.encode_utf16().flat_map(u16::to_be_bytes).collect();
|
||||
for v in [3, 1, 0x409, *id, utf16.len() as u16, strings.len() as u16] {
|
||||
name.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
strings.extend(utf16);
|
||||
}
|
||||
name.extend(strings);
|
||||
let mut fvar = Vec::new();
|
||||
for v in [1u16, 0, 16, 2, 1, 20, instances, 8] {
|
||||
fvar.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
fvar.extend_from_slice(b"wght");
|
||||
for v in [100u32 << 16, 400 << 16, 900 << 16] {
|
||||
fvar.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
fvar.extend_from_slice(&[0, 0, 0, 2]); // flags, axisNameId
|
||||
for i in 0..instances {
|
||||
fvar.extend_from_slice(&(256 + i).to_be_bytes());
|
||||
fvar.extend_from_slice(&[0, 0]);
|
||||
fvar.extend_from_slice(&(400u32 << 16).to_be_bytes());
|
||||
}
|
||||
mini_font(&[(*b"fvar", fvar), (*b"name", name)])
|
||||
}
|
||||
|
||||
/// A collection listing `font` `members` times.
|
||||
fn collection_of(font: &[u8], members: u32) -> Vec<u8> {
|
||||
let start = 12 + 4 * members;
|
||||
let mut out = b"ttcf\0\x01\0\0".to_vec();
|
||||
out.extend_from_slice(&members.to_be_bytes());
|
||||
for _ in 0..members {
|
||||
out.extend_from_slice(&start.to_be_bytes());
|
||||
}
|
||||
let mut font = font.to_vec();
|
||||
let tables = u16::from_be_bytes([font[4], font[5]]) as usize;
|
||||
for i in 0..tables {
|
||||
let at = 12 + 16 * i + 8;
|
||||
let offset = u32::from_be_bytes(font[at..at + 4].try_into().unwrap()) + start;
|
||||
font[at..at + 4].copy_from_slice(&offset.to_be_bytes());
|
||||
}
|
||||
out.extend(font);
|
||||
out
|
||||
}
|
||||
|
||||
/// JetBrains Mono renamed "MingLiU JBMono" (a name the interpreter is required for), with its
|
||||
/// `maxp` twilight-zone size set to `twilight`.
|
||||
fn hint_reliant_font(twilight: u16) -> Vec<u8> {
|
||||
let mut data = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/JetBrainsMono-Regular.ttf")).unwrap();
|
||||
let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
|
||||
for (from, to) in [(utf16("JetBrains Mono"), utf16("MingLiU JBMono")), (b"JetBrains Mono".to_vec(), b"MingLiU JBMono".to_vec())] {
|
||||
while let Some(at) = data.windows(from.len()).position(|w| w == from) {
|
||||
data[at..at + to.len()].copy_from_slice(&to);
|
||||
}
|
||||
}
|
||||
let tables = u16::from_be_bytes([data[4], data[5]]) as usize;
|
||||
let entry = (0..tables).map(|i| 12 + 16 * i).find(|e| &data[*e..e + 4] == b"maxp").unwrap();
|
||||
let maxp = u32::from_be_bytes(data[entry + 8..entry + 12].try_into().unwrap()) as usize;
|
||||
data[maxp + 16..maxp + 18].copy_from_slice(&twilight.to_be_bytes());
|
||||
data
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_font_asking_for_huge_hinting_zones_is_drawn_unhinted() {
|
||||
// #3692: skrifa's budget counts instructions, not the points each one moves.
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
db.add_font(hint_reliant_font(660));
|
||||
let face = db.face("MingLiU JBMono", "Regular");
|
||||
assert_eq!(face.family, "MingLiU JBMono");
|
||||
assert!(face.hinter().is_some(), "a real font's zones are hinted");
|
||||
assert!(!face.glyph_outline(face.glyph_for('a')).elements().is_empty());
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
db.add_font(hint_reliant_font(u16::MAX));
|
||||
let face = db.face("MingLiU JBMono", "Regular");
|
||||
assert!(face.hinter().is_none(), "a huge twilight zone is not hinted");
|
||||
assert!(!face.glyph_outline(face.glyph_for('a')).elements().is_empty(), "drawn unhinted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn the_draw_that_turns_hinting_off_is_redrawn_unhinted_for_the_outline_cache() {
|
||||
// #3692: the glyph whose slow hinted draw trips the breaker may be misassembled; it must not be
|
||||
// what the outline cache keeps.
|
||||
let data = hint_reliant_font(660);
|
||||
let mut face = super::make_face(
|
||||
super::FontBytes::Owned(std::sync::Arc::new(data)),
|
||||
0,
|
||||
"MingLiU JBMono".into(),
|
||||
"Regular".into(),
|
||||
"MingLiU JBMono".into(),
|
||||
Vec::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(face.hinter().is_some());
|
||||
face.hint_strike = std::time::Duration::ZERO; // every hinted draw is slow
|
||||
let gid = face.glyph_for('a');
|
||||
assert!(face.draw_outline(gid, usize::MAX).hinted, "hinted while the breaker is closed");
|
||||
assert!(face.draw_outline(gid, usize::MAX).hinted);
|
||||
let tripping = face.draw_outline(gid, usize::MAX);
|
||||
assert!(face.hinting_off.load(std::sync::atomic::Ordering::Relaxed), "the third slow draw trips the breaker");
|
||||
assert!(!tripping.hinted, "the tripping draw is returned unhinted");
|
||||
assert!(!tripping.path.elements().is_empty());
|
||||
// And what the cache keeps is that outline.
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
assert_eq!(db.outline(&face, gid).elements(), tripping.path.elements());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_instances_and_collection_members_are_capped() {
|
||||
// #3716: a file's faces are capped before they are built, however the file multiplies them.
|
||||
let font = variable_font(300);
|
||||
let faces = font_faces(&skrifa::FontRef::new(&font).unwrap(), 0);
|
||||
assert_eq!(faces.len(), MAX_FILE_FACES + 1, "one more than the cap, so a caller can tell");
|
||||
assert_eq!(faces[0].style, "Style 0");
|
||||
assert_eq!(enumerate_faces(&font, MAX_FILE_FACES).len(), MAX_FILE_FACES);
|
||||
// A million members, all the same font: read once, and no more than the cap.
|
||||
let started = std::time::Instant::now();
|
||||
let ttc = collection_of(BUNDLED[0], 1_000_000);
|
||||
let found = enumerate_faces(&ttc, MAX_FILE_FACES);
|
||||
assert_eq!(found.iter().map(|f| (f.family.as_str(), f.style.as_str())).collect::<Vec<_>>(), [(FALLBACK_FAMILY, "Regular")]);
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
assert_eq!(db.add_font(collection_of(&font, 1_000_000)), MAX_FILE_FACES);
|
||||
// A font the user adds is refused instead.
|
||||
assert!(matches!(db.install_font("v.ttf", &variable_font(300)), Err(user::UserFontError::Unusable(e)) if e.contains("more than 256")));
|
||||
// The face counts above are what tell the old code (a face per member) apart; the time
|
||||
// (under 0.1 s on a loaded machine) only backs up "read once", with room for a busy CI.
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed());
|
||||
}
|
||||
}
|
||||
+339
-11
@@ -149,8 +149,24 @@ pub fn decode_font_file(bytes: &[u8]) -> Result<Vec<u8>, UserFontError> {
|
||||
}
|
||||
let decoded = match sniff(bytes).ok_or(UserFontError::NotAFont)? {
|
||||
FontFormat::TrueType | FontFormat::OpenType | FontFormat::Collection => return Ok(bytes.to_vec()),
|
||||
FontFormat::Woff => unwrap_web_font(|| wuff::decompress_woff1(bytes))?,
|
||||
FontFormat::Woff2 => unwrap_web_font(|| wuff::decompress_woff2(bytes))?,
|
||||
FontFormat::Woff => {
|
||||
check_woff1_size(bytes)?;
|
||||
unwrap_web_font(|| wuff::decompress_woff1(bytes))?
|
||||
}
|
||||
FontFormat::Woff2 => {
|
||||
let mut too_many_points = false;
|
||||
let unwrapped = unwrap_web_font(|| {
|
||||
wuff::decompress_woff2_with_custom_brotli(bytes, &mut |data, size| {
|
||||
let stream = unbrotli(data, size)?;
|
||||
too_many_points = !woff2_points_fit(bytes, &stream);
|
||||
if too_many_points { Err(TOO_MANY_POINTS.into()) } else { Ok(stream) }
|
||||
})
|
||||
});
|
||||
if too_many_points {
|
||||
return Err(UserFontError::BadWebFont(TOO_MANY_POINTS.into()));
|
||||
}
|
||||
unwrapped?
|
||||
}
|
||||
};
|
||||
if decoded.len() > MAX_DECODED_BYTES {
|
||||
return Err(UserFontError::TooLarge { bytes: decoded.len() });
|
||||
@@ -161,6 +177,128 @@ pub fn decode_font_file(bytes: &[u8]) -> Result<Vec<u8>, UserFontError> {
|
||||
}
|
||||
}
|
||||
|
||||
/// WOFF 1.0: the font its table directory describes must fit [`MAX_DECODED_BYTES`]. The decoder
|
||||
/// sizes each table's buffer from the directory's `origLength` before inflating it (#3703).
|
||||
/// A short header or directory is left to the decoder to report.
|
||||
fn check_woff1_size(bytes: &[u8]) -> Result<(), UserFontError> {
|
||||
let u32_at = |at: usize| bytes.get(at..at.saturating_add(4)).and_then(|b| b.try_into().ok()).map(u32::from_be_bytes);
|
||||
let tables = bytes.get(12..14).and_then(|n| n.try_into().ok()).map_or(0, |n| usize::from(u16::from_be_bytes(n)));
|
||||
let mut total = 12 + 16 * tables;
|
||||
for i in 0..tables {
|
||||
let entry = 44 + 20 * i;
|
||||
let (Some(compressed), Some(orig)) = (u32_at(entry + 8), u32_at(entry + 12)) else { break };
|
||||
total = total.saturating_add((compressed.max(orig) as usize).saturating_add(3) & !3);
|
||||
}
|
||||
if total > MAX_DECODED_BYTES {
|
||||
return Err(UserFontError::TooLarge { bytes: total });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Why a WOFF2 file whose glyph has more points than a glyph may is refused.
|
||||
const TOO_MANY_POINTS: &str = "a glyph has more than 65536 points";
|
||||
|
||||
/// WOFF2's brotli stream, which must decompress to exactly `size` bytes (the decoder has checked
|
||||
/// `size` against its own cap and the compression ratio).
|
||||
fn unbrotli(data: &[u8], size: usize) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
|
||||
use std::io::Read;
|
||||
let mut out = Vec::with_capacity(size);
|
||||
brotli_decompressor::Decompressor::new(data, 4096).take(size as u64 + 1).read_to_end(&mut out)?;
|
||||
if out.len() != size {
|
||||
return Err("the compressed data doesn't have the size the directory gives".into());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Does every glyph in the WOFF2 file's transformed `glyf` tables have at most 65536 points, as
|
||||
/// the decoder requires? It allocates a glyph's points from its counts before checking them, so a
|
||||
/// small file could claim gigabytes (#3703). `stream` is the decompressed table data; anything
|
||||
/// that doesn't parse is refused too (the decoder would refuse it).
|
||||
fn woff2_points_fit(file: &[u8], stream: &[u8]) -> bool {
|
||||
woff2_transformed_glyf(file, stream).is_some_and(|tables| tables.iter().all(|t| glyf_points_fit(t).unwrap_or(false)))
|
||||
}
|
||||
|
||||
/// The transformed `glyf` tables in `stream`, found from the WOFF2 table directory in `file`
|
||||
/// (tables follow each other in directory order).
|
||||
fn woff2_transformed_glyf<'a>(file: &[u8], stream: &'a [u8]) -> Option<Vec<&'a [u8]>> {
|
||||
let tables = u16::from_be_bytes(file.get(12..14)?.try_into().ok()?);
|
||||
let (mut at, mut offset, mut glyf) = (48usize, 0usize, Vec::new());
|
||||
for _ in 0..tables {
|
||||
let flags = *file.get(at)?;
|
||||
at += 1;
|
||||
let tag: Option<&[u8]> = if flags & 63 == 63 {
|
||||
at += 4;
|
||||
Some(file.get(at - 4..at)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let is = |index: u8, name: &[u8]| flags & 63 == index || tag == Some(name);
|
||||
let (is_glyf, is_loca) = (is(10, b"glyf"), is(11, b"loca"));
|
||||
let orig = base128(file, &mut at)?;
|
||||
// glyf and loca are transformed at version 0, other tables at any other version.
|
||||
let transformed = if is_glyf || is_loca { flags >> 6 == 0 } else { flags >> 6 != 0 };
|
||||
let len = if transformed { base128(file, &mut at)? } else { orig };
|
||||
let end = offset.checked_add(len)?;
|
||||
if is_glyf && transformed {
|
||||
glyf.push(stream.get(offset..end)?);
|
||||
}
|
||||
offset = end;
|
||||
}
|
||||
Some(glyf)
|
||||
}
|
||||
|
||||
/// WOFF2's UIntBase128.
|
||||
fn base128(b: &[u8], at: &mut usize) -> Option<usize> {
|
||||
let mut v: u32 = 0;
|
||||
for i in 0..5 {
|
||||
let byte = *b.get(*at)?;
|
||||
*at += 1;
|
||||
if (i == 0 && byte == 0x80) || v >> 25 != 0 {
|
||||
return None;
|
||||
}
|
||||
v = v << 7 | u32::from(byte & 0x7f);
|
||||
if byte & 0x80 == 0 {
|
||||
return usize::try_from(v).ok();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Does every glyph of the transformed `glyf` table `t` have at most 65536 points?
|
||||
fn glyf_points_fit(t: &[u8]) -> Option<bool> {
|
||||
let u32_at = |at: usize| t.get(at..at + 4).and_then(|b| b.try_into().ok()).map(u32::from_be_bytes).map(|v| v as usize);
|
||||
let glyphs = usize::from(u16::from_be_bytes(t.get(4..6)?.try_into().ok()?));
|
||||
let contours_end = 36usize.checked_add(u32_at(8)?)?;
|
||||
let contours = t.get(36..contours_end)?;
|
||||
let points = t.get(contours_end..contours_end.checked_add(u32_at(12)?)?)?;
|
||||
let mut at = 0;
|
||||
for g in 0..glyphs {
|
||||
// The decoder reads the count as unsigned and only 0xFFFF is a composite glyph (no points
|
||||
// here): 0x8000..=0xFFFE are that many contours, so they are counted, not read as negative.
|
||||
let n = u16::from_be_bytes(contours.get(2 * g..2 * g + 2)?.try_into().ok()?);
|
||||
let mut total = 0usize;
|
||||
for _ in 0..if n == 0xFFFF { 0 } else { n } {
|
||||
// 255UInt16
|
||||
let code = *points.get(at)?;
|
||||
at += 1;
|
||||
let mut byte = || {
|
||||
at += 1;
|
||||
points.get(at - 1).copied().map(usize::from)
|
||||
};
|
||||
total += match code {
|
||||
253 => byte()? << 8 | byte()?,
|
||||
254 => byte()? + 253 * 2,
|
||||
255 => byte()? + 253,
|
||||
v => usize::from(v),
|
||||
};
|
||||
if total > 1 << 16 {
|
||||
return Some(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(true)
|
||||
}
|
||||
|
||||
/// Run a WOFF/WOFF2 decoder. The decoder checks its input, but it is third-party code reading
|
||||
/// hostile bytes: a panic in it is caught (native) and reported as a damaged file.
|
||||
fn unwrap_web_font(decode: impl FnOnce() -> Result<Vec<u8>, wuff::WuffErr>) -> Result<Vec<u8>, UserFontError> {
|
||||
@@ -230,7 +368,8 @@ impl FontDb {
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("couldn't store the font {name} in {}: {e}", dir.display());
|
||||
// Debug-quoted: a line break in the supplied name must not start a forged log record.
|
||||
log::warn!("couldn't store the font {name:?} in {}: {e}", dir.display());
|
||||
Some(format!("added for this session only: couldn't store it in {}: {e}", dir.display()))
|
||||
}
|
||||
},
|
||||
@@ -266,7 +405,7 @@ impl FontDb {
|
||||
if FileRef::new(&sfnt).is_err() {
|
||||
return Err(UserFontError::Unusable("not a valid OpenType file".into()));
|
||||
}
|
||||
let found = enumerate_faces(&sfnt);
|
||||
let found = enumerate_faces(&sfnt, MAX_FACES_PER_FILE + 1);
|
||||
if found.is_empty() {
|
||||
return Err(UserFontError::Unusable("no face with a family name".into()));
|
||||
}
|
||||
@@ -412,12 +551,29 @@ fn unique_name(name: &str, taken: impl Fn(&str) -> bool) -> String {
|
||||
/// Write `bytes` to a new file in `dir` named `name` (or a numbered variant when that exists),
|
||||
/// through a temporary file so a crash never leaves half a font.
|
||||
fn write_unique(dir: &Path, name: &str, bytes: &[u8]) -> std::io::Result<PathBuf> {
|
||||
// One store at a time: two adds racing for the same name would otherwise pick the same file
|
||||
// and unlink each other's temporary. ponytail: in-process only; two app instances adding the
|
||||
// same name at the same instant can still collide (the later rename wins).
|
||||
static STORE: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
let _held = STORE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let name = unique_name(name, |n| dir.join(n).exists());
|
||||
let path = dir.join(&name);
|
||||
let tmp = dir.join(format!(".{name}.part"));
|
||||
std::fs::write(&tmp, bytes)?;
|
||||
if let Err(e) = std::fs::rename(&tmp, &path) {
|
||||
// Created afresh, never opened through what is there: a link planted at the temporary name
|
||||
// would otherwise carry the write to a file outside the folder. A leftover from a crash (or
|
||||
// such a link) is unlinked, not followed, and the create tried once more.
|
||||
let create = || std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp);
|
||||
let mut file = match create() {
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
std::fs::remove_file(&tmp)?;
|
||||
create()?
|
||||
}
|
||||
r => r?,
|
||||
};
|
||||
let written = std::io::Write::write_all(&mut file, bytes);
|
||||
drop(file);
|
||||
if let Err(e) = written.and_then(|()| std::fs::rename(&tmp, &path)) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
@@ -564,25 +720,113 @@ pub(crate) mod tests {
|
||||
base128(data.len() as u32, &mut dir);
|
||||
stream.extend_from_slice(data);
|
||||
}
|
||||
let compressed = brotli_stored(&stream);
|
||||
let sfnt: usize = 12 + 16 * t.len() + t.iter().map(|(_, d)| d.len().next_multiple_of(4)).sum::<usize>();
|
||||
woff2_file(&font[..4], t.len(), &dir, &stream, sfnt)
|
||||
}
|
||||
|
||||
/// A WOFF2 file: header, the table directory `dir` of `n` tables, `stream` in stored brotli blocks.
|
||||
fn woff2_file(flavor: &[u8], n: usize, dir: &[u8], stream: &[u8], sfnt: usize) -> Vec<u8> {
|
||||
let compressed = brotli_stored(stream);
|
||||
let mut out = Vec::new();
|
||||
out.extend_from_slice(b"wOF2");
|
||||
out.extend_from_slice(&font[..4]);
|
||||
out.extend_from_slice(flavor);
|
||||
let total = (48 + dir.len() + compressed.len()).next_multiple_of(4);
|
||||
out.extend_from_slice(&(total as u32).to_be_bytes());
|
||||
out.extend_from_slice(&(t.len() as u16).to_be_bytes());
|
||||
out.extend_from_slice(&(n as u16).to_be_bytes());
|
||||
out.extend_from_slice(&0u16.to_be_bytes());
|
||||
let sfnt: usize = 12 + 16 * t.len() + t.iter().map(|(_, d)| d.len().next_multiple_of(4)).sum::<usize>();
|
||||
out.extend_from_slice(&(sfnt as u32).to_be_bytes());
|
||||
out.extend_from_slice(&(compressed.len() as u32).to_be_bytes());
|
||||
out.extend_from_slice(&[0, 1, 0, 0]);
|
||||
out.extend_from_slice(&[0; 20]);
|
||||
out.extend(dir);
|
||||
out.extend_from_slice(dir);
|
||||
out.extend(compressed);
|
||||
out.resize(total, 0);
|
||||
out
|
||||
}
|
||||
|
||||
/// A WOFF2 file holding a transformed `glyf` (and `loca`) whose one glyph has two contours of
|
||||
/// `points` points each (and no coordinates: the decoder finds it damaged in the end).
|
||||
fn woff2_glyph_with(points: u16) -> Vec<u8> {
|
||||
let [hi, lo] = points.to_be_bytes();
|
||||
woff2_glyph_with_contours(2, &[253, hi, lo].repeat(2), 2 * points as usize)
|
||||
}
|
||||
|
||||
/// As [`woff2_glyph_with`], for a glyph whose contour count is `n` and whose point-count stream
|
||||
/// is `counts`, with `total` flags.
|
||||
fn woff2_glyph_with_contours(n: u16, counts: &[u8], total: usize) -> Vec<u8> {
|
||||
let mut glyf = vec![0, 0, 0, 0, 0, 1, 0, 0]; // reserved, optionFlags, numGlyphs 1, indexFormat
|
||||
// nContour, nPoints, flag, glyph, composite, bbox and instruction stream sizes.
|
||||
for size in [2, counts.len(), total, 0, 0, 4, 0] {
|
||||
glyf.extend_from_slice(&(size as u32).to_be_bytes());
|
||||
}
|
||||
glyf.extend_from_slice(&n.to_be_bytes());
|
||||
glyf.extend_from_slice(counts); // 255UInt16 point counts
|
||||
glyf.resize(glyf.len() + total, 1); // on-curve flags
|
||||
glyf.extend_from_slice(&[0; 4]); // no explicit bounding boxes
|
||||
let mut dir = vec![10]; // glyf, transform version 0
|
||||
base128(4096, &mut dir);
|
||||
base128(glyf.len() as u32, &mut dir);
|
||||
dir.push(11); // loca, transformed away
|
||||
base128(8, &mut dir);
|
||||
base128(0, &mut dir);
|
||||
woff2_file(&[0, 1, 0, 0], 2, &dir, &glyf, 12 + 32 + 4096 + 8)
|
||||
}
|
||||
|
||||
/// A WOFF 1.0 file whose one table is a zlib stream of nothing that claims to inflate to `orig`
|
||||
/// bytes.
|
||||
fn woff1_claiming(orig: u32) -> Vec<u8> {
|
||||
let mut out = b"wOFF\0\x01\0\0".to_vec();
|
||||
out.extend_from_slice(&72u32.to_be_bytes()); // length
|
||||
out.extend_from_slice(&1u16.to_be_bytes()); // numTables
|
||||
out.extend_from_slice(&0u16.to_be_bytes());
|
||||
out.extend_from_slice(&orig.saturating_add(28).to_be_bytes()); // totalSfntSize
|
||||
out.extend_from_slice(&[0, 1, 0, 0]);
|
||||
out.extend_from_slice(&[0; 20]);
|
||||
out.extend_from_slice(b"glyf");
|
||||
for v in [64, 8, orig, 0] {
|
||||
out.extend_from_slice(&u32::to_be_bytes(v)); // offset, compLength, origLength, checksum
|
||||
}
|
||||
out.extend_from_slice(&[0x78, 0x9c, 0x03, 0x00, 0x00, 0x00, 0x00, 0x01]);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_woff_claiming_a_huge_table_is_refused_before_inflating() {
|
||||
// #3703: the decoder sizes a table's buffer from the directory before inflating it.
|
||||
for orig in [200 << 20, u32::MAX] {
|
||||
assert!(matches!(decode_font_file(&woff1_claiming(orig)), Err(UserFontError::TooLarge { .. })), "{orig}");
|
||||
}
|
||||
// A small claim reaches the decoder, which finds the data doesn't inflate to it.
|
||||
assert!(matches!(decode_font_file(&woff1_claiming(64)), Err(UserFontError::BadWebFont(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_woff2_glyph_with_too_many_points_is_refused_before_unpacking() {
|
||||
// #3703: the decoder allocates a glyph's points from these counts before checking them.
|
||||
assert_eq!(decode_font_file(&woff2_glyph_with(40_000)), Err(UserFontError::BadWebFont(TOO_MANY_POINTS.into())));
|
||||
// Within the limit the scan passes the file on, to the decoder.
|
||||
assert!(matches!(decode_font_file(&woff2_glyph_with(3)), Err(UserFontError::BadWebFont(e)) if e != TOO_MANY_POINTS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_woff2_glyph_with_a_contour_count_past_i16_max_is_counted() {
|
||||
// #3703: the decoder reads the contour count as unsigned (only 0xFFFF is a composite), so
|
||||
// 0x8000..=0xFFFE are that many contours; read as negative they counted no points and the
|
||||
// decoder then allocated about 1 GB for a 1.3 MB font.
|
||||
for n in [0x7FFFu16, 0x8000, 0xFFFE] {
|
||||
let file = woff2_glyph_with_contours(n, &vec![3; usize::from(n)], 3 * usize::from(n));
|
||||
assert_eq!(decode_font_file(&file), Err(UserFontError::BadWebFont(TOO_MANY_POINTS.into())), "{n:#x}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_woff2_composite_glyph_is_not_counted_as_having_points() {
|
||||
// 0xFFFF marks a composite glyph: the point check skips it (the decoder reads its own
|
||||
// streams), so it is not refused for its points.
|
||||
let file = woff2_glyph_with_contours(0xFFFF, &[], 0);
|
||||
assert!(matches!(decode_font_file(&file), Err(UserFontError::BadWebFont(e)) if e != TOO_MANY_POINTS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_added_font_is_listed_resolved_and_measured() {
|
||||
let db = db();
|
||||
@@ -703,6 +947,44 @@ pub(crate) mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The messages logged while this test binary runs.
|
||||
fn logged() -> &'static std::sync::Mutex<Vec<String>> {
|
||||
static LOG: std::sync::OnceLock<std::sync::Mutex<Vec<String>>> = std::sync::OnceLock::new();
|
||||
struct Capture;
|
||||
impl log::Log for Capture {
|
||||
fn enabled(&self, _: &log::Metadata) -> bool {
|
||||
true
|
||||
}
|
||||
fn log(&self, r: &log::Record) {
|
||||
if let Some(l) = LOG.get() {
|
||||
l.lock().unwrap().push(r.args().to_string());
|
||||
}
|
||||
}
|
||||
fn flush(&self) {}
|
||||
}
|
||||
LOG.get_or_init(|| {
|
||||
let _ = log::set_logger(&Capture);
|
||||
log::set_max_level(log::LevelFilter::Warn);
|
||||
std::sync::Mutex::new(Vec::new())
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_supplied_name_is_logged_escaped() {
|
||||
// #3692: a line break in the name must not start a forged record in the log file.
|
||||
let log = logged();
|
||||
let not_a_folder = temp_dir("not-a-folder");
|
||||
std::fs::write(¬_a_folder, b"x").unwrap();
|
||||
let db = db();
|
||||
db.load_user_fonts(¬_a_folder);
|
||||
let r = db.install_font("a\nERROR forged record.ttf", &renamed("SourceSans3-Regular.ttf", FAMILY)).unwrap();
|
||||
assert!(r.warning.is_some(), "the font can't be stored");
|
||||
let lines = log.lock().unwrap();
|
||||
let line = lines.iter().find(|l| l.contains("forged record")).unwrap();
|
||||
assert!(!line.contains('\n'), "{line}");
|
||||
let _ = std::fs::remove_file(¬_a_folder);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_names_are_safe_and_unique() {
|
||||
let ttf = renamed("SourceSans3-Regular.ttf", FAMILY);
|
||||
@@ -711,4 +993,50 @@ pub(crate) mod tests {
|
||||
assert_eq!(stored_name("-rf.otf", b"OTTO"), "font-rf.otf");
|
||||
assert_eq!(unique_name("a.ttf", |n| n == "a.ttf" || n == "a-2.ttf"), "a-3.ttf");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_link_planted_at_the_temporary_name_is_not_written_through() {
|
||||
// #3703 review: the temporary file is created afresh, so a planted link can't carry the
|
||||
// font bytes to a file outside the folder.
|
||||
let root = std::env::temp_dir().join(format!("wordcraft-font-part-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let dir = root.join("fonts");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let outside = root.join("outside.txt");
|
||||
std::fs::write(&outside, b"keep me").unwrap();
|
||||
std::os::unix::fs::symlink(&outside, dir.join(".a.ttf.part")).unwrap();
|
||||
let path = write_unique(&dir, "a.ttf", b"font bytes").unwrap();
|
||||
assert_eq!(std::fs::read(&outside).unwrap(), b"keep me");
|
||||
assert_eq!(path, dir.join("a.ttf"));
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"font bytes");
|
||||
assert!(!std::fs::symlink_metadata(&path).unwrap().file_type().is_symlink());
|
||||
assert!(std::fs::symlink_metadata(dir.join(".a.ttf.part")).is_err(), "the temporary name is gone");
|
||||
// A leftover from a crash doesn't block the next add either.
|
||||
std::fs::write(dir.join(".b.ttf.part"), b"half a font").unwrap();
|
||||
assert_eq!(std::fs::read(write_unique(&dir, "b.ttf", b"whole").unwrap()).unwrap(), b"whole");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fonts_stored_at_once_under_one_name_each_get_their_own_file() {
|
||||
// #3703 review: concurrent adds of one name never share a temporary or a destination.
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-font-race-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let paths: Vec<_> = (0..8u8)
|
||||
.map(|i| {
|
||||
let dir = dir.clone();
|
||||
std::thread::spawn(move || (i, write_unique(&dir, "a.ttf", &[i; 64]).unwrap()))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.map(|t| t.join().unwrap())
|
||||
.collect();
|
||||
let distinct: std::collections::HashSet<_> = paths.iter().map(|(_, p)| p.clone()).collect();
|
||||
assert_eq!(distinct.len(), 8);
|
||||
for (i, p) in &paths {
|
||||
assert_eq!(std::fs::read(p).unwrap(), vec![*i; 64], "{}", p.display());
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -270,7 +270,7 @@ fn the_scan_lists_a_variable_fonts_named_instances_as_loading_it_does() {
|
||||
return;
|
||||
};
|
||||
let data = std::fs::read(path).unwrap();
|
||||
let loaded: Vec<(String, String, String)> = enumerate_faces(&data).into_iter().map(|f| (f.family, f.style, f.legacy)).collect();
|
||||
let loaded: Vec<(String, String, String)> = enumerate_faces(&data, MAX_FILE_FACES).into_iter().map(|f| (f.family, f.style, f.legacy)).collect();
|
||||
assert_eq!(names(path), loaded);
|
||||
if loaded.len() > 1 {
|
||||
// Instances other than Regular, Bold and Italic have names of their own.
|
||||
|
||||
@@ -25,4 +25,5 @@ chrono = { workspace = true }
|
||||
js-sys = "0.3"
|
||||
|
||||
[dev-dependencies]
|
||||
wordcraft-doc = { workspace = true, features = ["test-support"] }
|
||||
proptest = { workspace = true }
|
||||
@@ -724,7 +724,7 @@ fn apply_style(css: &str, f: &mut Fmt, align: &mut Option<Align>, preserve: &mut
|
||||
if model::is_mono(&fam) || fam.eq_ignore_ascii_case("monospace") {
|
||||
f.code = true;
|
||||
} else if !fam.is_empty() && !matches!(fam.to_ascii_lowercase().as_str(), "serif" | "sans-serif" | "inherit" | "initial") {
|
||||
f.font = Some(fam);
|
||||
f.font = model::kept_font(&fam).or(f.font.take());
|
||||
}
|
||||
}
|
||||
"vertical-align" => {
|
||||
@@ -1226,7 +1226,7 @@ impl<'r> Builder<'r> {
|
||||
if model::is_mono(&fam) {
|
||||
fmt.code = true;
|
||||
} else if !fam.is_empty() {
|
||||
fmt.font = Some(fam);
|
||||
fmt.font = model::kept_font(&fam).or(fmt.font.take());
|
||||
}
|
||||
}
|
||||
if let Some(sz) = attr(attrs, "size") {
|
||||
@@ -1245,7 +1245,7 @@ impl<'r> Builder<'r> {
|
||||
}
|
||||
"a" => {
|
||||
if let Some(h) = attr(attrs, "href").filter(|h| wordcraft_doc::link::is_safe_href(h)) {
|
||||
fmt.link = Some(h.trim().to_string());
|
||||
fmt.link = model::kept_link(h.trim());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -1588,17 +1588,20 @@ fn collapse_ws(s: &str) -> String {
|
||||
|
||||
/// Parse HTML into a document.
|
||||
pub fn import(s: &str) -> Document {
|
||||
model::begin_import();
|
||||
model::to_doc(&parse(s))
|
||||
}
|
||||
|
||||
/// [`import`], loading pictures that aren't `data:` URIs through `images`.
|
||||
pub fn import_with(s: &str, images: ImageLoader) -> Document {
|
||||
model::begin_import();
|
||||
model::to_doc(&parse_with(s, images))
|
||||
}
|
||||
|
||||
/// [`import_with`] onto `page` (its size and margins), which tables size to: HTML gives no page
|
||||
/// size, so the caller picks it (the region's paper, #1381).
|
||||
pub fn import_on(s: &str, images: ImageLoader, page: &wordcraft_doc::SectionProps) -> Document {
|
||||
model::begin_import();
|
||||
model::to_doc_in(&parse_with(s, images), page.clone())
|
||||
}
|
||||
|
||||
@@ -2424,4 +2427,17 @@ mod tests {
|
||||
assert!(html.contains("vertical-align:middle"));
|
||||
assert!(html.contains("vertical-align:bottom"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_formatted_leading_breaks_trim_quickly() {
|
||||
// #3705: a paragraph opening with alternating bold and italic `<br>` was trimmed one
|
||||
// span at a time with `Vec::remove(0)`.
|
||||
let html = format!("<p>{}x</p>", "<b><br></b><i><br></i>".repeat(100_000));
|
||||
let t = std::time::Instant::now();
|
||||
let f = parse(&html);
|
||||
// About a second on a loaded machine; trimming one span at a time ran past 6 minutes.
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed());
|
||||
let texts: Vec<String> = f.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p.text()) } else { None }).collect();
|
||||
assert_eq!(texts, ["x"]);
|
||||
}
|
||||
}
|
||||
+51
-12
@@ -243,8 +243,10 @@ impl Writer {
|
||||
}
|
||||
self.out.push_str("\\begin{verbatim}\n");
|
||||
for l in lines {
|
||||
// The environment ends at the first `\end{verbatim}`, so keep it from appearing.
|
||||
let l: String = l.replace("\\end{verbatim}", "\\end {verbatim}").chars().filter(|c| *c == '\t' || !c.is_control()).collect();
|
||||
// The environment ends at the first `\end{verbatim}`, so keep it from appearing. The
|
||||
// control characters go first: removing one afterwards could join a delimiter back up.
|
||||
let l: String = l.chars().filter(|c| *c == '\t' || !c.is_control()).collect();
|
||||
let l = l.replace("\\end{verbatim}", "\\end {verbatim}");
|
||||
self.out.push_str(&l);
|
||||
self.out.push('\n');
|
||||
}
|
||||
@@ -1055,6 +1057,7 @@ fn math_seq(t: &[MTok], pos: &mut usize, depth: usize, in_opt: bool) -> String {
|
||||
|
||||
/// Import a LaTeX source file.
|
||||
pub fn import(bytes: &[u8]) -> Document {
|
||||
model::begin_import();
|
||||
let mut d = model::to_doc(&parse(&crate::txt::decode(bytes.get(..MAX_SOURCE).unwrap_or(bytes))));
|
||||
d.ensure_nonempty();
|
||||
d
|
||||
@@ -2333,20 +2336,21 @@ impl<'a> Parser<'a> {
|
||||
}
|
||||
}
|
||||
"href" => {
|
||||
let url = self.arg_raw();
|
||||
self.with_arg(move |f| f.link = Some(url));
|
||||
let url = model::kept_link(&self.arg_raw());
|
||||
self.with_arg(move |f| f.link = url);
|
||||
}
|
||||
"url" | "nolinkurl" => {
|
||||
let url = self.arg_raw();
|
||||
let mut f = self.fmt();
|
||||
if name == "url" {
|
||||
f.link = Some(url.clone());
|
||||
f.link = model::kept_link(&url);
|
||||
}
|
||||
self.para.push_text(&url, &f);
|
||||
}
|
||||
"hyperlink" => {
|
||||
let target = self.arg_raw();
|
||||
self.with_arg(move |f| f.link = Some(format!("#{target}")));
|
||||
let link = model::kept_link(&format!("#{target}"));
|
||||
self.with_arg(move |f| f.link = link);
|
||||
}
|
||||
"hypertarget" => {
|
||||
let target = self.arg_raw();
|
||||
@@ -2360,7 +2364,7 @@ impl<'a> Parser<'a> {
|
||||
"ref" | "eqref" | "pageref" | "autoref" | "cref" | "Cref" | "nameref" => {
|
||||
let l = self.arg_raw();
|
||||
let mut f = self.fmt();
|
||||
f.link = Some(format!("#{l}"));
|
||||
f.link = model::kept_link(&format!("#{l}"));
|
||||
self.para.push_text(&l, &f);
|
||||
}
|
||||
"cite" | "citep" | "citet" | "parencite" | "textcite" | "autocite" => {
|
||||
@@ -2671,19 +2675,24 @@ fn table(toks: &[Tok], spec: &str, depth: usize) -> FTable {
|
||||
for cell_toks in r {
|
||||
let mut cell = Cell::default();
|
||||
let mut content = trim_cell(cell_toks);
|
||||
// `\multicolumn{n}{spec}{content}`, `\multirow{n}{width}{content}`.
|
||||
// `\multicolumn{n}{spec}{content}`, `\multirow{n}{width}{content}`. Each wrapper
|
||||
// rescans its content, so only MAX_NEST of them are unwrapped (a cell nests one or
|
||||
// two); further ones stay in the content.
|
||||
let mut sub = Parser::new(content, depth + 1);
|
||||
let mut wrappers = 0usize;
|
||||
loop {
|
||||
sub.skip_spaces();
|
||||
match sub.t.get(sub.pos) {
|
||||
Some(Tok::Cmd(c)) if c == "multicolumn" => {
|
||||
Some(Tok::Cmd(c)) if c == "multicolumn" && wrappers < MAX_NEST => {
|
||||
wrappers += 1;
|
||||
sub.pos += 1;
|
||||
cell.colspan = sub.arg_raw().trim().parse::<u32>().unwrap_or(1).clamp(1, 63);
|
||||
let _ = sub.arg_raw();
|
||||
content = sub.arg_tokens();
|
||||
sub = Parser::new(content, depth + 1);
|
||||
}
|
||||
Some(Tok::Cmd(c)) if c == "multirow" || c == "multirow*" => {
|
||||
Some(Tok::Cmd(c)) if (c == "multirow" || c == "multirow*") && wrappers < MAX_NEST => {
|
||||
wrappers += 1;
|
||||
sub.pos += 1;
|
||||
let _ = sub.opt_arg();
|
||||
cell.rowspan = sub.arg_raw().trim().parse::<i64>().map(|n| n.unsigned_abs().clamp(1, 1000) as u32).unwrap_or(1);
|
||||
@@ -3161,13 +3170,13 @@ Caf\'e na\"ive \c{c}a \v{s}\'{e} and {\bfseries grouped bold} after.
|
||||
let FBlock::Para(p) = &flow.blocks[0] else { panic!() };
|
||||
assert_eq!(
|
||||
p.inlines[0],
|
||||
Inline::Text("Homepage".to_string(), Fmt { link: Some("https://example.com/~user/page".to_string()), ..Default::default() })
|
||||
Inline::Text("Homepage".to_string(), Fmt { link: Some("https://example.com/~user/page".into()), ..Default::default() })
|
||||
);
|
||||
assert_eq!(
|
||||
p.inlines[2],
|
||||
Inline::Text(
|
||||
"https://example.com/~user/page".to_string(),
|
||||
Fmt { link: Some("https://example.com/~user/page".to_string()), ..Default::default() }
|
||||
Fmt { link: Some("https://example.com/~user/page".into()), ..Default::default() }
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -3234,4 +3243,34 @@ Caf\'e na\"ive \c{c}a \v{s}\'{e} and {\bfseries grouped bold} after.
|
||||
let Some(Block::Table(bt_bordered)) = back_bordered.body.first().map(|b| &**b) else { panic!() };
|
||||
assert!(bt_bordered.props.style.is_some(), "imported table should remain bordered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verbatim_end_is_kept_out_after_control_characters_go() {
|
||||
// A control character inside `\end{verbatim}` was removed after the check for it, so
|
||||
// the code line closed the environment and the rest ran as TeX.
|
||||
let mut d = Document::new();
|
||||
let line = "\\end{verb\u{E}atim}\\input{/etc/passwd}";
|
||||
d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(line, Default::default()).styled(model::CODE_STYLE))];
|
||||
let out = export(&d);
|
||||
assert_eq!(out.matches("\\end{verbatim}").count(), 1, "{out}");
|
||||
assert!(out.contains("\\end {verbatim}\\input"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_multicolumn_wrappers_are_bounded() {
|
||||
// Each `\multicolumn` unwrapped rescanned the rest of the cell; nested ones were all
|
||||
// unwrapped, quadratic in the nesting.
|
||||
let n = 50_000;
|
||||
let src = format!("\\begin{{tabular}}{{l}}{}x{}\\\\\\end{{tabular}}", "\\multicolumn{1}{c}{".repeat(n), "}".repeat(n));
|
||||
let t = std::time::Instant::now();
|
||||
let f = parse(&src);
|
||||
// Under a second on a loaded machine; unwrapping every wrapper took 213 s.
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(10), "{:?}", t.elapsed());
|
||||
assert!(f.blocks.iter().any(|b| matches!(b, FBlock::Table(_))));
|
||||
// One or two wrappers, as written in real tables, are still read.
|
||||
let f = parse("\\begin{tabular}{ll}\\multicolumn{2}{c}{\\multirow{2}{*}{Both}}\\\\\\end{tabular}");
|
||||
let Some(FBlock::Table(t)) = f.blocks.first() else { panic!("{:?}", f.blocks) };
|
||||
assert_eq!((t.rows[0][0].colspan, t.rows[0][0].rowspan), (2, 2));
|
||||
assert_eq!(t.rows[0][0].text(), "Both");
|
||||
}
|
||||
}
|
||||
@@ -91,5 +91,7 @@ pub fn export_with(ext: &str, doc: &Document, opts: &ExportOptions) -> Option<Re
|
||||
Some(out.map(|bytes| comments::append(&ext, doc, bytes)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test_log;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
+119
-28
@@ -10,6 +10,7 @@
|
||||
//! Export writes the same constructs; pictures become `` placeholders.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::{Align, Document};
|
||||
|
||||
@@ -24,16 +25,19 @@ const MAX_NEST: usize = 32;
|
||||
|
||||
/// Parse Markdown text into a document.
|
||||
pub fn import(text: &str) -> Document {
|
||||
model::begin_import();
|
||||
model::to_doc(&parse(text))
|
||||
}
|
||||
|
||||
/// [`import`] onto `page`: Markdown gives no page size (#1381).
|
||||
pub fn import_on(text: &str, page: &wordcraft_doc::SectionProps) -> Document {
|
||||
model::begin_import();
|
||||
model::to_doc_in(&parse(text), page.clone())
|
||||
}
|
||||
|
||||
/// Link reference definitions: the normalised label to its destination.
|
||||
pub type Refs = HashMap<String, String>;
|
||||
/// A destination is shared by every reference to it, not copied into each (#3713).
|
||||
pub type Refs = HashMap<String, Arc<str>>;
|
||||
|
||||
/// A label as CommonMark compares them: case-folded, and any run of white space one space
|
||||
/// (§4.7, "matches"). `[FOO bar]` and `[foo BAR]` are the same label.
|
||||
@@ -141,7 +145,7 @@ fn take_definitions(lines: &mut Vec<String>) -> Refs {
|
||||
if starts_block && let Some((label, dest)) = link_definition(line) {
|
||||
// The first definition of a label wins, as CommonMark says, and definitions may
|
||||
// follow one another.
|
||||
refs.entry(label).or_insert(dest);
|
||||
refs.entry(label).or_insert(dest.into());
|
||||
return false;
|
||||
}
|
||||
starts_block = is_blank(line) || atx_heading(t).is_some() || thematic_break(t);
|
||||
@@ -799,10 +803,18 @@ fn link_dest(s: &str, at: usize) -> Option<(String, usize)> {
|
||||
i += ws;
|
||||
let r = s.get(i..)?;
|
||||
let (dest, used) = if let Some(inner) = r.strip_prefix('<') {
|
||||
let end = inner.find(['>', '\n'])?;
|
||||
if inner.get(end..)?.starts_with('\n') {
|
||||
return None;
|
||||
}
|
||||
// The first `>` not escaped by a backslash ends it; a line break first means no link.
|
||||
let b = inner.as_bytes();
|
||||
let mut k = 0;
|
||||
let end = loop {
|
||||
match b.get(k)? {
|
||||
b'\\' => k += 1,
|
||||
b'>' => break k,
|
||||
b'\n' => return None,
|
||||
_ => {}
|
||||
}
|
||||
k += 1;
|
||||
};
|
||||
(inner.get(..end)?.to_string(), end + 2)
|
||||
} else {
|
||||
let mut depth = 0i32;
|
||||
@@ -983,7 +995,7 @@ fn tokenize(s: &str, base: &Fmt, depth: usize, refs: &Refs) -> Vec<Node> {
|
||||
let dest = link_dest(s, close + 1);
|
||||
scan_budget = scan_budget.saturating_sub(dest.as_ref().map_or(s.len(), |d| d.1).saturating_sub(close));
|
||||
let (dest, end) = dest?;
|
||||
return Some((text.to_string(), dest, end));
|
||||
return Some((text.to_string(), Arc::from(dest), end));
|
||||
}
|
||||
// A reference link: `[text][label]`, `[text][]` and `[text]` all take
|
||||
// their destination from a definition collected before parsing (#3238).
|
||||
@@ -1020,7 +1032,11 @@ fn tokenize(s: &str, base: &Fmt, depth: usize, refs: &Refs) -> Vec<Node> {
|
||||
Some((inner, dest, end)) if base.link.is_none() => {
|
||||
flush!();
|
||||
let mut f = base.clone();
|
||||
f.link = Some(dest);
|
||||
// A destination too long to keep leaves its text unlinked.
|
||||
f.link = (dest.len() <= model::MAX_LINK).then_some(dest);
|
||||
if f.link.is_none() {
|
||||
model::dropped(model::Dropped::Link);
|
||||
}
|
||||
for x in parse_inlines(&inner, &f, depth + 1, refs) {
|
||||
nodes.push(Node::Inl(x));
|
||||
}
|
||||
@@ -1047,7 +1063,7 @@ fn tokenize(s: &str, base: &Fmt, depth: usize, refs: &Refs) -> Vec<Node> {
|
||||
if is_auto {
|
||||
flush!();
|
||||
let mut f = base.clone();
|
||||
f.link = Some(if inner.contains('@') && !inner.contains(':') { format!("mailto:{inner}") } else { inner.to_string() });
|
||||
f.link = Some(if inner.contains('@') && !inner.contains(':') { format!("mailto:{inner}").into() } else { inner.into() });
|
||||
push_text(&mut nodes, inner, &f);
|
||||
i += inner.len() + 2;
|
||||
text_start = i;
|
||||
@@ -1202,18 +1218,16 @@ fn emit(mut nodes: Vec<Node>) -> Vec<Inline> {
|
||||
}
|
||||
out.push(Inline::Text(t.to_string(), f));
|
||||
};
|
||||
let base_of = |nodes: &[Node], k: usize| -> Fmt {
|
||||
// Delimiter literals take the formatting (link, …) of neighbouring text.
|
||||
for j in (0..k).rev() {
|
||||
if let Some(Node::Text(_, f)) = nodes.get(j) {
|
||||
return Fmt { bold: false, italic: false, strike: false, ..f.clone() };
|
||||
}
|
||||
}
|
||||
Fmt::default()
|
||||
};
|
||||
for (k, n) in nodes.iter().enumerate() {
|
||||
// Delimiter literals take the formatting (link, …) of the text before them: the last text
|
||||
// node, tracked here rather than searched for backwards from every delimiter (quadratic on
|
||||
// a paragraph of delimiters).
|
||||
let mut last_text: Option<&Fmt> = None;
|
||||
for n in &nodes {
|
||||
match n {
|
||||
Node::Text(t, f) => push(&mut out, t, f, em, strong, strike),
|
||||
Node::Text(t, f) => {
|
||||
push(&mut out, t, f, em, strong, strike);
|
||||
last_text = Some(f);
|
||||
}
|
||||
Node::Inl(Inline::Text(t, f)) => push(&mut out, t, f, em, strong, strike),
|
||||
Node::Inl(other) => out.push(other.clone()),
|
||||
Node::Delim { ch, left, opens, closes, .. } => {
|
||||
@@ -1224,9 +1238,11 @@ fn emit(mut nodes: Vec<Node>) -> Vec<Inline> {
|
||||
Emph::Strike => strike = strike.saturating_sub(1),
|
||||
}
|
||||
}
|
||||
let lit: String = std::iter::repeat_n(*ch, *left).collect();
|
||||
let f = base_of(&nodes, k);
|
||||
push(&mut out, &lit, &f, em, strong, strike);
|
||||
if *left > 0 {
|
||||
let lit: String = std::iter::repeat_n(*ch, *left).collect();
|
||||
let f = last_text.map_or_else(Fmt::default, |f| Fmt { bold: false, italic: false, strike: false, ..f.clone() });
|
||||
push(&mut out, &lit, &f, em, strong, strike);
|
||||
}
|
||||
for e in opens {
|
||||
match e {
|
||||
Emph::Em => em += 1,
|
||||
@@ -1378,7 +1394,7 @@ fn inlines_md_impl(inlines: &[Inline], in_table: bool) -> String {
|
||||
}
|
||||
let mut out = String::new();
|
||||
let mut stack: Vec<Mark> = Vec::new();
|
||||
let mut link: Option<String> = None;
|
||||
let mut link: Option<Arc<str>> = None;
|
||||
let close_all = |out: &mut String, stack: &mut Vec<Mark>| {
|
||||
while let Some(m) = stack.pop() {
|
||||
out.push_str(marker(m));
|
||||
@@ -1436,8 +1452,35 @@ fn inlines_md_impl(inlines: &[Inline], in_table: bool) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// `l` as a Markdown link destination that reads back as exactly `l`, the target
|
||||
/// `is_safe_href` passed. What Markdown would decode (a backslash escape, an entity reference)
|
||||
/// or end the destination on (`<`, `>`) is backslash-escaped, never deleted: deleting `<` or `>`
|
||||
/// could turn an accepted relative target into `javascript:…` or `//host`. Control characters
|
||||
/// can't be written in a destination and are left out, as the safety check ignores them.
|
||||
fn link_target(l: &str) -> String {
|
||||
if l.contains([' ', '(', ')', '<', '>']) { format!("<{}>", l.replace(['<', '>', '\n'], "")) } else { l.to_string() }
|
||||
let l: String = l.chars().filter(|c| !c.is_ascii_control()).collect();
|
||||
let mut out = String::with_capacity(l.len() + 2);
|
||||
for (i, c) in l.char_indices() {
|
||||
let rest = l.get(i + c.len_utf8()..).unwrap_or("");
|
||||
match c {
|
||||
'<' | '>' => out.push('\\'),
|
||||
// A backslash is literal unless punctuation (or the closing `)`/`>`) follows it.
|
||||
'\\' if rest.chars().next().is_none_or(|n| n.is_ascii_punctuation()) => out.push('\\'),
|
||||
'&' if entity_like(rest) => out.push('\\'),
|
||||
_ => {}
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
if l.contains(|c: char| c.is_whitespace() || matches!(c, '(' | ')' | '<' | '>')) { format!("<{out}>") } else { out }
|
||||
}
|
||||
|
||||
/// Whether `rest` (the text after a `&`) starts like an entity reference (`amp;`, `#106;`,
|
||||
/// `#x6A;`), which a Markdown reader would decode.
|
||||
fn entity_like(rest: &str) -> bool {
|
||||
let Some(end) = within(rest, 34).find(';') else { return false };
|
||||
let body = rest.get(..end).unwrap_or("");
|
||||
let body = body.strip_prefix('#').map_or(body, |n| n.strip_prefix(['x', 'X']).unwrap_or(n));
|
||||
!body.is_empty() && body.chars().all(|c| c.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
fn marker(m: Mark) -> &'static str {
|
||||
@@ -1640,7 +1683,7 @@ mod tests {
|
||||
let s = inl("see [the *site*](http://x.org \"T\") and `a*b*` \\*lit\\* & <https://y.com>");
|
||||
let link: Vec<_> = s
|
||||
.iter()
|
||||
.filter_map(|i| if let Inline::Text(t, f) = i { f.link.as_ref().map(|l| (t.clone(), l.clone(), f.italic)) } else { None })
|
||||
.filter_map(|i| if let Inline::Text(t, f) = i { f.link.as_ref().map(|l| (t.clone(), l.to_string(), f.italic)) } else { None })
|
||||
.collect();
|
||||
assert_eq!(link[0], ("the ".into(), "http://x.org".into(), false));
|
||||
assert_eq!(link[1], ("site".into(), "http://x.org".into(), true));
|
||||
@@ -1661,7 +1704,7 @@ mod tests {
|
||||
.inlines
|
||||
.iter()
|
||||
.filter_map(|x| match x {
|
||||
Inline::Text(t, f) => f.link.clone().map(|l| (t.clone(), l)),
|
||||
Inline::Text(t, f) => f.link.as_deref().map(|l| (t.clone(), l.to_string())),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
@@ -2067,7 +2110,8 @@ mod tests {
|
||||
use wordcraft_doc::{CharProps, Document, Paragraph, StoryRef, para_block};
|
||||
let cases = ["Shopping\n- milk\n# not a heading", "a\n1. x", "a\n2) x", "a\n+ x", "a\n---", "a\n***", "a\n==", "a\n--", "a\n> q"];
|
||||
for s in cases {
|
||||
let d = Document { body: vec![para_block(Paragraph::with_text(s, CharProps::default()))], ..Default::default() };
|
||||
let mut d = Document::new();
|
||||
d.body = vec![para_block(Paragraph::with_text(s, CharProps::default()))];
|
||||
let md = export(&d);
|
||||
let back = import(&md);
|
||||
assert_eq!(back.body.len(), 1, "{s:?} -> {md:?}");
|
||||
@@ -2152,4 +2196,51 @@ mod tests {
|
||||
let texts: Vec<String> = flow.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p.text()) } else { None }).collect();
|
||||
assert_eq!(texts, ["Hello world!", "After comment."]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paragraph_of_delimiters_parses_in_linear_time() {
|
||||
// #3714: each delimiter's literal looked back for the text before it, to the start of
|
||||
// the paragraph when there was none (quadratic).
|
||||
let src = "*_".repeat(200_000);
|
||||
let t = std::time::Instant::now();
|
||||
let out = inl(&src);
|
||||
// Under a second on a loaded machine; the backwards search ran past 6 minutes.
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(10), "{:?}", t.elapsed());
|
||||
let text: String = out.iter().filter_map(|i| if let Inline::Text(t, _) = i { Some(t.as_str()) } else { None }).collect();
|
||||
assert!(text.chars().all(|c| matches!(c, '*' | '_')), "only delimiters come out");
|
||||
// Literal delimiters still take the link of the text before them.
|
||||
let s = inl("[a](http://x.org) [b*](http://y.org)");
|
||||
assert!(s.iter().any(|i| matches!(i, Inline::Text(t, f) if t == "b*" && f.link.as_deref() == Some("http://y.org"))), "{s:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exported_link_destinations_read_back_unchanged() {
|
||||
// Removing `<` and `>` from a destination after the safety check could make a relative
|
||||
// target a script or network-path one (`java<script:…` → `javascript:…`). They are
|
||||
// escaped instead, as are backslash escapes and entity references a reader would decode.
|
||||
let md = |l: &str| inlines_md(&[Inline::Text("x".into(), Fmt { link: Some(l.into()), ..Default::default() })]);
|
||||
assert_eq!(md("java<script:alert(1)"), "[x](<java\\<script:alert(1)>)");
|
||||
for l in [
|
||||
"java<script:alert(1)",
|
||||
"/</evil.example/(x)",
|
||||
"a>b c",
|
||||
"javascript:alert(1)",
|
||||
"javascript:x",
|
||||
"&",
|
||||
"javascript\\:x",
|
||||
"dir\\",
|
||||
"dir\\(1)\\",
|
||||
"q?a=1&b=2",
|
||||
] {
|
||||
let out = md(l);
|
||||
let back = inl(&out);
|
||||
let got: Vec<_> =
|
||||
back.iter().filter_map(|i| if let Inline::Text(_, f) = i { f.link.as_deref().map(str::to_string) } else { None }).collect();
|
||||
assert_eq!(got, [l.to_string()], "{l:?} → {out:?}");
|
||||
}
|
||||
// Ordinary destinations are written as before.
|
||||
assert_eq!(md("https://example.com/a?b=1&c=2"), "[x](https://example.com/a?b=1&c=2)");
|
||||
assert_eq!(md("https://example.com/a b"), "[x](<https://example.com/a b>)");
|
||||
assert_eq!(md("https://e.com/x\ny"), "[x](https://e.com/xy)");
|
||||
}
|
||||
}
|
||||
+140
-18
@@ -19,6 +19,84 @@ pub const MONO_FONT: &str = "Courier New";
|
||||
pub const CODE_STYLE: &str = "Code";
|
||||
/// Nesting limit for tables in tables (hostile input).
|
||||
pub const MAX_DEPTH: usize = 8;
|
||||
/// Longest font name the importers keep, in bytes (as the .docx reader); a longer one (no real
|
||||
/// font has one) is dropped (#3713).
|
||||
pub const MAX_FONT_NAME: usize = 256;
|
||||
/// Longest hyperlink target the importers keep, in bytes; a longer link is dropped and its text
|
||||
/// kept (#3713).
|
||||
pub const MAX_LINK: usize = 8 << 10;
|
||||
/// Link and font bytes the document's runs may carry between them. A flow's spans share one
|
||||
/// copy of a name ([`Fmt`]), but every run of a document owns its own: past this budget, runs
|
||||
/// lose their link and font rather than multiply a small file into gigabytes (#3713).
|
||||
const MAX_RUN_STRINGS: usize = 64 << 20;
|
||||
|
||||
/// `name` as a span's font, unless it is longer than [`MAX_FONT_NAME`] (then reported, see
|
||||
/// [`dropped`]).
|
||||
pub fn kept_font(name: &str) -> Option<Arc<str>> {
|
||||
let kept = (name.len() <= MAX_FONT_NAME).then(|| name.into());
|
||||
if kept.is_none() {
|
||||
dropped(Dropped::Font);
|
||||
}
|
||||
kept
|
||||
}
|
||||
|
||||
/// `target` as a span's link, unless it is longer than [`MAX_LINK`] (then reported, see
|
||||
/// [`dropped`]).
|
||||
pub fn kept_link(target: &str) -> Option<Arc<str>> {
|
||||
let kept = (target.len() <= MAX_LINK).then(|| target.into());
|
||||
if kept.is_none() {
|
||||
dropped(Dropped::Link);
|
||||
}
|
||||
kept
|
||||
}
|
||||
|
||||
/// What an importer leaves out to stay within a limit (review of #3873: no limit drops data
|
||||
/// without saying so).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Dropped {
|
||||
/// A font name over [`MAX_FONT_NAME`].
|
||||
Font,
|
||||
/// A link target over [`MAX_LINK`].
|
||||
Link,
|
||||
/// Runs' links and fonts past [`MAX_RUN_STRINGS`].
|
||||
RunStrings,
|
||||
/// A style name cut to [`MAX_FONT_NAME`].
|
||||
StyleName,
|
||||
}
|
||||
|
||||
impl Dropped {
|
||||
fn message(self) -> String {
|
||||
match self {
|
||||
Dropped::Font => format!("import: font names longer than {MAX_FONT_NAME} bytes are dropped; that text keeps the surrounding font"),
|
||||
Dropped::Link => format!("import: links longer than {MAX_LINK} bytes are dropped; their text is kept"),
|
||||
Dropped::RunStrings => {
|
||||
format!("import: the document's runs carry more than {MAX_RUN_STRINGS} bytes of links and font names; later runs lose them")
|
||||
}
|
||||
Dropped::StyleName => format!("import: style names longer than {MAX_FONT_NAME} bytes are cut short"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// The kinds of [`Dropped`] already reported for the document being imported on this thread.
|
||||
/// Per thread because a document is parsed and built on one, and the parsers drop things
|
||||
/// from places that have no log of their own to hand.
|
||||
static REPORTED: std::cell::Cell<u8> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Log that the importer left something of `kind` out: once per kind per imported document
|
||||
/// ([`begin_import`]), not once per item.
|
||||
pub(crate) fn dropped(kind: Dropped) {
|
||||
let bit = 1u8 << (kind as u8);
|
||||
if REPORTED.with(|r| r.replace(r.get() | bit)) & bit == 0 {
|
||||
log::warn!("{}", kind.message());
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a new document's reports: each importer's entry point calls this first.
|
||||
pub(crate) fn begin_import() {
|
||||
REPORTED.with(|r| r.set(0));
|
||||
}
|
||||
|
||||
/// Character formatting the text formats can express.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
@@ -30,11 +108,13 @@ pub struct Fmt {
|
||||
pub code: bool,
|
||||
pub sup: bool,
|
||||
pub sub: bool,
|
||||
pub link: Option<String>,
|
||||
/// Shared: every span of a link holds the same target.
|
||||
pub link: Option<Arc<str>>,
|
||||
pub color: Option<Rgb>,
|
||||
/// Points.
|
||||
pub size: Option<f32>,
|
||||
pub font: Option<String>,
|
||||
/// Shared, as `link`.
|
||||
pub font: Option<Arc<str>>,
|
||||
/// Background (highlight / shading).
|
||||
pub background: Option<Rgb>,
|
||||
/// Tracked insertion of this text (formats that keep revisions: RTF).
|
||||
@@ -239,15 +319,13 @@ impl Para {
|
||||
/// Remove the characters in `start` from the start of the paragraph's text and those in
|
||||
/// `end` from its end.
|
||||
pub fn trim_with(&mut self, start: &[char], end: &[char]) {
|
||||
while let Some(Inline::Text(t, _)) = self.inlines.first_mut() {
|
||||
let n = t.trim_start_matches(start).len();
|
||||
let cut = t.len() - n;
|
||||
// The leading spans that trim to nothing go in one `drain`: removing them one at a time
|
||||
// shifted the rest each time, quadratic in a run of separately formatted line breaks.
|
||||
let k = self.inlines.iter().take_while(|i| matches!(i, Inline::Text(t, _) if t.trim_start_matches(start).is_empty())).count();
|
||||
self.inlines.drain(..k);
|
||||
if let Some(Inline::Text(t, _)) = self.inlines.first_mut() {
|
||||
let cut = t.len() - t.trim_start_matches(start).len();
|
||||
t.replace_range(..cut, "");
|
||||
if t.is_empty() {
|
||||
self.inlines.remove(0);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
while let Some(Inline::Text(t, _)) = self.inlines.last_mut() {
|
||||
let n = t.trim_end_matches(end).len();
|
||||
@@ -431,10 +509,10 @@ pub fn char_props(f: &Fmt) -> CharProps {
|
||||
} else {
|
||||
None
|
||||
},
|
||||
link: f.link.clone().filter(|l| !l.is_empty()),
|
||||
link: f.link.as_deref().filter(|l| !l.is_empty()).map(str::to_string),
|
||||
color: f.color.map(TextColor::Rgb),
|
||||
size: f.size.filter(|s| s.is_finite() && *s >= 1.0 && *s <= 1638.0),
|
||||
font: if f.code { Some(MONO_FONT.to_string()) } else { f.font.clone().filter(|s| !s.is_empty()) },
|
||||
font: if f.code { Some(MONO_FONT.to_string()) } else { f.font.as_deref().filter(|s| !s.is_empty()).map(str::to_string) },
|
||||
shading: f.background,
|
||||
..Default::default()
|
||||
}
|
||||
@@ -518,6 +596,8 @@ struct Builder<'a> {
|
||||
doc: &'a mut Document,
|
||||
/// Revisions already in `doc` by (kind, author, date), so each change is added once.
|
||||
revs: std::collections::HashMap<(u8, RevMark), u32>,
|
||||
/// Link and font bytes given to runs so far ([`MAX_RUN_STRINGS`]).
|
||||
run_strings: usize,
|
||||
}
|
||||
|
||||
fn rev_key(kind: RevisionKind) -> u8 {
|
||||
@@ -537,7 +617,7 @@ impl<'a> Builder<'a> {
|
||||
revs.entry((rev_key(r.kind), mark)).or_insert(i as u32);
|
||||
}
|
||||
}
|
||||
Builder { doc, revs }
|
||||
Builder { doc, revs, run_strings: 0 }
|
||||
}
|
||||
|
||||
/// The document revision for a tracked change, added the first time it is seen.
|
||||
@@ -552,6 +632,22 @@ impl<'a> Builder<'a> {
|
||||
i
|
||||
}
|
||||
|
||||
/// `cp` as a run may keep it: without its link and font once the runs' copies of them are
|
||||
/// over budget.
|
||||
fn budget(&mut self, mut cp: CharProps) -> CharProps {
|
||||
let n = cp.link.as_ref().map_or(0, String::len).saturating_add(cp.font.as_ref().map_or(0, String::len));
|
||||
match self.run_strings.checked_add(n).filter(|&t| t <= MAX_RUN_STRINGS) {
|
||||
Some(t) => self.run_strings = t,
|
||||
None => {
|
||||
if cp.link.is_some() || cp.font.is_some() {
|
||||
dropped(Dropped::RunStrings);
|
||||
}
|
||||
(cp.link, cp.font) = (None, None);
|
||||
}
|
||||
}
|
||||
cp
|
||||
}
|
||||
|
||||
fn para(&mut self, p: &Para, list_num: Option<u32>) -> Paragraph {
|
||||
let mut out = Paragraph::new();
|
||||
out.props = para_props(p, list_num);
|
||||
@@ -577,11 +673,12 @@ impl<'a> Builder<'a> {
|
||||
match i {
|
||||
Inline::Text(t, f) => {
|
||||
let t = clean_text(t);
|
||||
let mut cp = char_props(f);
|
||||
let mut cp = self.budget(char_props(f));
|
||||
cp.ins = f.ins.as_ref().map(|m| self.revision(RevisionKind::Insert, m));
|
||||
cp.del = f.del.as_ref().map(|m| self.revision(RevisionKind::Delete, m));
|
||||
if let Some(c) = &f.fmt_change {
|
||||
cp.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, &c.mark), char_props(&c.old));
|
||||
let old = self.budget(char_props(&c.old));
|
||||
cp.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, &c.mark), old);
|
||||
}
|
||||
if p.kind == Kind::Code {
|
||||
// Code's font comes from its style, before a change as after it.
|
||||
@@ -616,7 +713,7 @@ impl<'a> Builder<'a> {
|
||||
}
|
||||
Inline::Ruby { base, ruby, fmt } => {
|
||||
let (base, ruby) = (clean_text(base), clean_text(ruby));
|
||||
let cp = char_props(fmt);
|
||||
let cp = self.budget(char_props(fmt));
|
||||
// A source without a base or without a reading (malformed markup) is just its
|
||||
// text: a guide that annotates nothing is no guide.
|
||||
if base.is_empty() || ruby.is_empty() {
|
||||
@@ -883,13 +980,13 @@ fn fmt_of(doc: &Document, cp: &CharProps, in_code: bool) -> Fmt {
|
||||
code,
|
||||
sup: e.vert_align == Some(VertAlign::Superscript),
|
||||
sub: e.vert_align == Some(VertAlign::Subscript),
|
||||
link: e.link.clone(),
|
||||
link: e.link.as_deref().map(Arc::from),
|
||||
color: match e.color {
|
||||
Some(TextColor::Rgb(c)) => Some(c),
|
||||
_ => None,
|
||||
},
|
||||
size: e.size,
|
||||
font: if code || in_code { None } else { e.shown_font().map(str::to_string) },
|
||||
font: if code || in_code { None } else { e.shown_font().map(Arc::from) },
|
||||
background: e.highlight.and_then(|h| h.rgb()).or(e.shading),
|
||||
ins: None,
|
||||
del: None,
|
||||
@@ -1558,4 +1655,29 @@ mod tests {
|
||||
assert_eq!(t.rows[1].len(), 2);
|
||||
assert!(t.rows[1][0].covered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_drops_a_long_run_of_blank_spans_in_linear_time() {
|
||||
// Separately formatted line breaks don't merge; each used to be removed with
|
||||
// `Vec::remove(0)`, shifting all the rest (quadratic).
|
||||
let mut p = Para::default();
|
||||
let (b, i) = (Fmt { bold: true, ..Default::default() }, Fmt { italic: true, ..Default::default() });
|
||||
for _ in 0..200_000 {
|
||||
p.push_text("\n", &b);
|
||||
p.push_text(" ", &i);
|
||||
}
|
||||
p.push_text(" x ", &Fmt::default());
|
||||
let t = std::time::Instant::now();
|
||||
p.trim();
|
||||
// A fraction of a second; `Vec::remove(0)` per span ran past 6 minutes.
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(10), "{:?}", t.elapsed());
|
||||
assert_eq!(p.inlines, vec![Inline::Text("x".into(), Fmt::default())]);
|
||||
// The first span left keeps its formatting and loses only its leading blanks.
|
||||
let mut p = Para::default();
|
||||
p.push_text(" \t", &b);
|
||||
p.push_text(" a ", &i);
|
||||
p.push_text("b ", &b);
|
||||
p.trim();
|
||||
assert_eq!(p.inlines, vec![Inline::Text("a ".into(), i), Inline::Text("b".into(), b)]);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
use quick_xml::XmlVersion;
|
||||
use quick_xml::events::{BytesStart, Event};
|
||||
@@ -1034,7 +1035,7 @@ struct FmtDelta {
|
||||
color: Option<Rgb>,
|
||||
background: Option<Rgb>,
|
||||
size: Option<f32>,
|
||||
font: Option<String>,
|
||||
font: Option<Arc<str>>,
|
||||
code: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -1340,7 +1341,7 @@ impl Styles {
|
||||
if model::is_mono(&fam) || get(&a, "style:font-pitch").is_some_and(|p| p.trim().eq_ignore_ascii_case("fixed")) {
|
||||
d.code = Some(true);
|
||||
} else if !fam.is_empty() {
|
||||
d.font = Some(fam);
|
||||
d.font = model::kept_font(&fam);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1446,7 +1447,7 @@ impl Styles {
|
||||
|
||||
/// The document's default font: the Standard paragraph style's, else the default style's.
|
||||
fn doc_font(&self) -> Option<String> {
|
||||
self.map.get("Standard").filter(|s| !s.auto).and_then(|s| s.fmt.font.clone()).or_else(|| self.default_font.clone())
|
||||
self.map.get("Standard").filter(|s| !s.auto).and_then(|s| s.fmt.font.as_deref().map(str::to_string)).or_else(|| self.default_font.clone())
|
||||
}
|
||||
|
||||
fn chain<'a>(&'a self, name: &'a str) -> Vec<(&'a str, &'a StyleInfo)> {
|
||||
@@ -2053,7 +2054,7 @@ impl Body<'_> {
|
||||
"a" => {
|
||||
let mut f = self.cur_fmt();
|
||||
if let Some(h) = get(a, "xlink:href").filter(|h| !h.is_empty()) {
|
||||
f.link = Some(h.to_string());
|
||||
f.link = model::kept_link(h);
|
||||
}
|
||||
if let Some(s) = get(a, "text:style-name") {
|
||||
f = self.styles.text_fmt(s, &f);
|
||||
@@ -2626,6 +2627,7 @@ fn parse_tracked(bytes: &[u8]) -> Result<(Flow, SectionProps, Option<bool>, Opti
|
||||
|
||||
/// Parse an `.odt` package into a document.
|
||||
pub fn import(bytes: &[u8]) -> Result<Document, String> {
|
||||
model::begin_import();
|
||||
let (flow, last, track, font, parts) = parse_tracked(bytes)?;
|
||||
let mut doc = model::to_doc(&flow);
|
||||
if font.is_some() {
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
//! tables. Group nesting and picture sizes are capped.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use wordcraft_doc::props::VAlign;
|
||||
use wordcraft_doc::{Align, Document, Rgb};
|
||||
@@ -306,11 +307,13 @@ impl Writer {
|
||||
"\\nosupersub"
|
||||
});
|
||||
}
|
||||
let font = |f: &Fmt| if f.code { Some(model::MONO_FONT.to_string()) } else { f.font.clone() };
|
||||
fn font(f: &Fmt) -> Option<&str> {
|
||||
if f.code { Some(model::MONO_FONT) } else { f.font.as_deref() }
|
||||
}
|
||||
let old_font = font(old);
|
||||
if old_font != font(cur) {
|
||||
// No font is the document's default font, font 0 (Normal's).
|
||||
let i = old_font.map_or(0, |n| self.t.font(&n));
|
||||
let i = old_font.map_or(0, |n| self.t.font(n));
|
||||
ctl.push_str(&format!("\\f{i}"));
|
||||
}
|
||||
let half_points = |s: Option<f32>| s.map(|s| (s * 2.0).round().clamp(2.0, 3276.0) as i32);
|
||||
@@ -1106,7 +1109,7 @@ struct Reader {
|
||||
st: St,
|
||||
stack: Vec<St>,
|
||||
overflow: usize,
|
||||
fonts: HashMap<i32, String>,
|
||||
fonts: HashMap<i32, Arc<str>>,
|
||||
font_cur: i32,
|
||||
font_name: String,
|
||||
/// Code pages of the fonts whose charset names one (`\fcharset204` → 1251).
|
||||
@@ -1398,7 +1401,9 @@ impl Reader {
|
||||
|
||||
fn commit_font(&mut self) {
|
||||
let name = std::mem::take(&mut self.font_name).trim().trim_end_matches(';').trim().to_string();
|
||||
if !name.is_empty() && self.fonts.len() < 4096 {
|
||||
if let Some(name) = model::kept_font(&name).filter(|n| !n.is_empty())
|
||||
&& self.fonts.len() < 4096
|
||||
{
|
||||
self.fonts.insert(self.font_cur, name);
|
||||
if let Some(cp) = self.font_cp_cur.take() {
|
||||
self.font_cps.insert(self.font_cur, cp);
|
||||
@@ -1626,7 +1631,8 @@ impl Reader {
|
||||
for c in s.chars() {
|
||||
if c == ';' {
|
||||
self.commit_font();
|
||||
} else {
|
||||
} else if self.font_name.len() <= model::MAX_FONT_NAME {
|
||||
// One past the limit is enough for `commit_font` to drop the name.
|
||||
self.font_name.push(c);
|
||||
}
|
||||
}
|
||||
@@ -1646,8 +1652,10 @@ impl Reader {
|
||||
for c in s.chars() {
|
||||
if c == ';' {
|
||||
self.commit_style();
|
||||
} else {
|
||||
} else if self.style_name.len() < model::MAX_FONT_NAME {
|
||||
self.style_name.push(c);
|
||||
} else {
|
||||
model::dropped(model::Dropped::StyleName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2329,7 +2337,7 @@ impl Reader {
|
||||
"fldrslt" => {
|
||||
let link = self.st.field.and_then(|i| self.fields.get(i)).and_then(|s| hyperlink(s));
|
||||
if link.is_some() {
|
||||
self.st.fmt.link = link;
|
||||
self.st.fmt.link = link.as_deref().and_then(model::kept_link);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -2807,7 +2815,7 @@ fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
|
||||
if blocks.is_empty() {
|
||||
blocks.push(FBlock::Para(Para::default()));
|
||||
}
|
||||
let default_font = r.fonts.get(&r.deff).filter(|n| !model::is_mono(n)).cloned();
|
||||
let default_font = r.fonts.get(&r.deff).filter(|n| !model::is_mono(n)).map(|n| n.to_string());
|
||||
Ok(Parsed { flow: Flow { blocks, meta: r.meta }, page: r.page, last_section, default_font, headers_footers })
|
||||
}
|
||||
|
||||
@@ -2850,6 +2858,7 @@ fn column_grid(rows: &mut [Vec<Cell>], rights: &[Vec<i32>]) -> Vec<f32> {
|
||||
|
||||
/// Parse RTF bytes into a document.
|
||||
pub fn import(bytes: &[u8]) -> Result<Document, String> {
|
||||
model::begin_import();
|
||||
let Parsed { flow, last_section, default_font, headers_footers, .. } = parse_full(bytes)?;
|
||||
let mut doc = model::to_doc_in(&flow, last_section);
|
||||
// Each section's own headers and footers; those it leaves out stay linked to the previous.
|
||||
|
||||
+138
-18
@@ -18,7 +18,7 @@
|
||||
//! all), sheets, rows, columns, cells and string lengths are capped, repeat counts are clamped to
|
||||
//! what is left, and nothing recurses, so XML depth costs nothing.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::io::{Cursor, Read};
|
||||
|
||||
use quick_xml::XmlVersion;
|
||||
@@ -138,18 +138,28 @@ pub fn read_rows(bytes: &[u8], pick: Pick<'_>) -> Result<(Vec<String>, Vec<Vec<S
|
||||
/// repeats numbered (`Name`, `Name 2`), ignoring case as merge fields do.
|
||||
pub fn field_names(row: &[String]) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::with_capacity(row.len());
|
||||
// The names given so far, ASCII-lowercased, and per base name the next number to try: a set
|
||||
// lookup per candidate, and each base resumes where it stopped, instead of rescanning every
|
||||
// name from `2` for every repeat (cubic on a row of equal headers).
|
||||
let mut taken: HashSet<String> = HashSet::with_capacity(row.len());
|
||||
let mut next: HashMap<String, usize> = HashMap::new();
|
||||
for (i, h) in row.iter().enumerate() {
|
||||
let base = match h.trim() {
|
||||
"" => format!("Column {}", i + 1),
|
||||
t => t.to_string(),
|
||||
};
|
||||
let taken = |n: &str, out: &[String]| out.iter().any(|o| o.eq_ignore_ascii_case(n));
|
||||
let mut name = base.clone();
|
||||
let mut k = 2usize;
|
||||
while taken(&name, &out) {
|
||||
name = format!("{base} {k}");
|
||||
k = k.saturating_add(1);
|
||||
if taken.contains(&name.to_ascii_lowercase()) {
|
||||
let k = next.entry(base.to_ascii_lowercase()).or_insert(2);
|
||||
loop {
|
||||
name = format!("{base} {k}");
|
||||
*k = k.saturating_add(1);
|
||||
if !taken.contains(&name.to_ascii_lowercase()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
taken.insert(name.to_ascii_lowercase());
|
||||
out.push(name);
|
||||
}
|
||||
out
|
||||
@@ -333,6 +343,8 @@ struct Grid {
|
||||
rows: Vec<Vec<String>>,
|
||||
cells: usize,
|
||||
text: TextBudget,
|
||||
/// A row has been left out for its text (and logged, once per sheet).
|
||||
refused: bool,
|
||||
}
|
||||
|
||||
impl Grid {
|
||||
@@ -340,6 +352,15 @@ impl Grid {
|
||||
self.rows.len() >= MAX_ROWS || self.cells >= MAX_CELLS || self.text.0 >= MAX_TEXT_BYTES
|
||||
}
|
||||
|
||||
/// Note that a row is left out because its text is over what is left of [`MAX_TEXT_BYTES`]:
|
||||
/// logged once per sheet, not once per row.
|
||||
fn refuse(&mut self) {
|
||||
if !self.refused {
|
||||
self.refused = true;
|
||||
log::warn!("sheet: rows with more text than the {MAX_TEXT_BYTES} byte budget has left are left out of the recipient list");
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep `row` (trailing blanks trimmed) `times` times, as far as the caps allow.
|
||||
fn push(&mut self, mut row: Vec<String>, times: u64) {
|
||||
while row.last().is_some_and(String::is_empty) {
|
||||
@@ -350,14 +371,15 @@ impl Grid {
|
||||
}
|
||||
let mut left = times;
|
||||
while left > 0 && !self.full() {
|
||||
let room = MAX_CELLS.saturating_sub(self.cells);
|
||||
let mut r = row.clone();
|
||||
r.truncate(room);
|
||||
// What fits is measured on `row` itself; only a copy that will be kept is made.
|
||||
let keep = row.len().min(MAX_CELLS.saturating_sub(self.cells));
|
||||
let r = row.get(..keep).unwrap_or_default();
|
||||
if !self.text.take(r.iter().map(String::len).sum()) {
|
||||
self.refuse();
|
||||
return;
|
||||
}
|
||||
self.cells = self.cells.saturating_add(r.len());
|
||||
self.rows.push(r);
|
||||
self.rows.push(r.to_vec());
|
||||
left -= 1;
|
||||
}
|
||||
}
|
||||
@@ -854,7 +876,9 @@ fn ods_read(xml: &[u8], want: Option<usize>) -> Result<(Vec<String>, Vec<Vec<Str
|
||||
// one is the wanted sheet.
|
||||
let mut tables = 0usize;
|
||||
let mut collecting = false;
|
||||
let mut row: Option<(Vec<String>, u64, u64)> = None; // cells, pending blank cells, repeat
|
||||
// Cells, pending blank cells, repeat, and the cells' text bytes (`None` once the row is over
|
||||
// the text budget and so refused).
|
||||
let mut row: Option<(Vec<String>, u64, u64, Option<usize>)> = None;
|
||||
let mut cell: Option<OCell> = None;
|
||||
let (mut in_p, mut skip) = (0usize, 0usize);
|
||||
walk(xml, |ev| match ev {
|
||||
@@ -875,7 +899,7 @@ fn ods_read(xml: &[u8], want: Option<usize>) -> Result<(Vec<String>, Vec<Vec<Str
|
||||
match n {
|
||||
b"table-row" => {
|
||||
let repeat = attr(e, b"number-rows-repeated").and_then(|v| v.trim().parse().ok()).unwrap_or(1u64).max(1);
|
||||
row = Some((Vec::new(), 0, repeat));
|
||||
row = Some((Vec::new(), 0, repeat, Some(0)));
|
||||
}
|
||||
b"table-cell" | b"covered-table-cell" if row.is_some() => {
|
||||
let repeat = attr(e, b"number-columns-repeated").and_then(|v| v.trim().parse().ok()).unwrap_or(1u64).max(1);
|
||||
@@ -959,22 +983,33 @@ fn ods_read(xml: &[u8], want: Option<usize>) -> Result<(Vec<String>, Vec<Vec<Str
|
||||
if n == b"covered-table-cell" {
|
||||
skip = skip.saturating_sub(1);
|
||||
}
|
||||
let (Some(c), Some((cells, pending, _))) = (cell.take(), row.as_mut()) else { return };
|
||||
let (Some(c), Some((cells, pending, _, budget))) = (cell.take(), row.as_mut()) else { return };
|
||||
let Some(bytes) = budget else { return };
|
||||
let text = ods_value(&c);
|
||||
if text.is_empty() {
|
||||
*pending = pending.saturating_add(c.repeat);
|
||||
return;
|
||||
}
|
||||
// Blank cells before this one, then the value as often as it repeats, as far
|
||||
// as the columns allow.
|
||||
let blanks = (*pending).min(MAX_COLS.saturating_sub(cells.len()) as u64) as usize;
|
||||
// as the columns and the grid's cells left (all `Grid::push` keeps) allow.
|
||||
let room = MAX_COLS.min(MAX_CELLS.saturating_sub(grid.cells));
|
||||
let blanks = (*pending).min(room.saturating_sub(cells.len()) as u64) as usize;
|
||||
let times = c.repeat.min(room.saturating_sub(cells.len().saturating_add(blanks)) as u64) as usize;
|
||||
// The row's text has to fit what is left of the budget to be kept at all
|
||||
// (`Grid::push`): one that can't is refused now, before the copies are made.
|
||||
*bytes = bytes.saturating_add(text.len().saturating_mul(times));
|
||||
if *bytes > MAX_TEXT_BYTES.saturating_sub(grid.text.0) {
|
||||
cells.clear();
|
||||
*budget = None;
|
||||
grid.refuse();
|
||||
return;
|
||||
}
|
||||
cells.resize(cells.len() + blanks, String::new());
|
||||
*pending = 0;
|
||||
let times = c.repeat.min(MAX_COLS.saturating_sub(cells.len()) as u64) as usize;
|
||||
cells.extend(std::iter::repeat_n(text, times));
|
||||
}
|
||||
b"table-row" => {
|
||||
if let Some((cells, _, repeat)) = row.take() {
|
||||
if let Some((cells, _, repeat, Some(_))) = row.take() {
|
||||
grid.push(cells, repeat);
|
||||
}
|
||||
}
|
||||
@@ -997,7 +1032,12 @@ fn ods_value(c: &OCell) -> String {
|
||||
("time", Some(v)) => iso_time(v).unwrap_or_else(|| c.text.clone()),
|
||||
("boolean", Some(v)) => if v.eq_ignore_ascii_case("true") || v == "1" { "TRUE" } else { "FALSE" }.into(),
|
||||
("percentage" | "currency", Some(v)) if c.text.trim().is_empty() => v.parse::<f64>().map(number).unwrap_or_else(|_| c.text.clone()),
|
||||
("string", Some(v)) if c.text.trim().is_empty() => v.to_string(),
|
||||
("string", Some(v)) if c.text.trim().is_empty() => {
|
||||
// Attribute text obeys the same per-cell cap as `<text:p>` text.
|
||||
let mut s = String::new();
|
||||
push_capped(&mut s, v);
|
||||
s
|
||||
}
|
||||
_ => c.text.clone(),
|
||||
}
|
||||
}
|
||||
@@ -1204,4 +1244,84 @@ mod tests {
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0], vec!["TRUE", "World", "Name"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_headers_are_numbered_in_linear_time() {
|
||||
// Every repeat used to restart at `Name 2` and compare each candidate with every name
|
||||
// so far: cubic in the number of equal headers.
|
||||
let row = vec!["Name".to_string(); 5_000];
|
||||
let t = std::time::Instant::now();
|
||||
let names = field_names(&row);
|
||||
// Milliseconds; the old numbering ran past 6 minutes.
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(10), "{:?}", t.elapsed());
|
||||
assert_eq!(names.len(), 5_000);
|
||||
assert_eq!(names[0], "Name");
|
||||
assert_eq!(names[4_999], "Name 5000");
|
||||
// Names a header already has stay collision-free, ignoring case.
|
||||
let row: Vec<String> = ["Name", "Name 2", "Name", "name", "", "Column 5", "NAME 2"].iter().map(|s| s.to_string()).collect();
|
||||
assert_eq!(field_names(&row), ["Name", "Name 2", "Name 3", "name 4", "Column 5", "Column 5 2", "NAME 2 2"]);
|
||||
}
|
||||
|
||||
fn ods(rows: &str) -> Vec<u8> {
|
||||
let content = format!(
|
||||
r#"<office:document-content xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"><office:body><office:spreadsheet><table:table table:name="Sheet1">{rows}</table:table></office:spreadsheet></office:body></office:document-content>"#
|
||||
);
|
||||
zip(&[("mimetype", "application/vnd.oasis.opendocument.spreadsheet"), ("content.xml", &content)])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ods_string_attributes_are_capped_before_repeating() {
|
||||
// An `office:string-value` is a cell's text: it obeys MAX_CELL_CHARS like `<text:p>`
|
||||
// does, so repeating it across the columns can't build a huge row first.
|
||||
let long = "v".repeat(MAX_CELL_CHARS * 4);
|
||||
let book = ods(&format!(
|
||||
r#"<table:table-row><table:table-cell office:value-type="string" office:string-value="{long}" table:number-columns-repeated="3"/></table:table-row>"#
|
||||
));
|
||||
let (_, rows) = read_rows(&book, Pick::Index(0)).unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].len(), 3);
|
||||
assert!(rows[0].iter().all(|c| c.chars().count() == MAX_CELL_CHARS));
|
||||
|
||||
// A row whose repeated text alone is over the text budget is dropped as it is built
|
||||
// (it was built in full, then cloned, then refused); the rows after it still count.
|
||||
let wide = "€".repeat(MAX_CELL_CHARS);
|
||||
let book = ods(&format!(
|
||||
r#"<table:table-row><table:table-cell office:value-type="string" office:string-value="{wide}" table:number-columns-repeated="1000"/></table:table-row><table:table-row><table:table-cell><text:p>next</text:p></table:table-cell></table:table-row>"#
|
||||
));
|
||||
let ((_, rows), warned) = crate::test_log::warnings(|| read_rows(&book, Pick::Index(0)).unwrap());
|
||||
assert_eq!(rows, vec![vec!["next".to_string()]]);
|
||||
// The row left out is said to be (review of #3873).
|
||||
assert_eq!(warned.iter().filter(|w| w.contains("are left out of the recipient list")).count(), 1, "{warned:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_over_the_text_budget_are_logged_once_per_sheet() {
|
||||
let wide = "€".repeat(MAX_CELL_CHARS);
|
||||
let over = format!(
|
||||
r#"<table:table-row><table:table-cell office:value-type="string" office:string-value="{wide}" table:number-columns-repeated="1000"/></table:table-row>"#
|
||||
);
|
||||
let book = ods(&format!("{over}{over}{over}<table:table-row><table:table-cell><text:p>next</text:p></table:table-cell></table:table-row>"));
|
||||
let ((_, rows), warned) = crate::test_log::warnings(|| read_rows(&book, Pick::Index(0)).unwrap());
|
||||
assert_eq!(rows, vec![vec!["next".to_string()]]);
|
||||
assert_eq!(warned.iter().filter(|w| w.contains("recipient list")).count(), 1, "one warning, not one per row: {warned:?}");
|
||||
let book = ods("<table:table-row><table:table-cell><text:p>fine</text:p></table:table-cell></table:table-row>");
|
||||
let (_, warned) = crate::test_log::warnings(|| read_rows(&book, Pick::Index(0)).unwrap());
|
||||
assert!(warned.is_empty(), "{warned:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ods_repeated_text_is_budgeted_for_the_cells_the_grid_keeps() {
|
||||
// One cell left in the grid: of a long value repeated a thousand times only the first
|
||||
// copy is kept, and it fits the text budget, so the row isn't refused for the others.
|
||||
let wide = "€".repeat(MAX_CELL_CHARS);
|
||||
let full = MAX_CELLS / MAX_COLS - 1;
|
||||
let book = ods(&format!(
|
||||
r#"<table:table-row table:number-rows-repeated="{full}"><table:table-cell office:value-type="string" office:string-value="x" table:number-columns-repeated="{MAX_COLS}"/></table:table-row><table:table-row><table:table-cell office:value-type="string" office:string-value="x" table:number-columns-repeated="{}"/></table:table-row><table:table-row><table:table-cell office:value-type="string" office:string-value="{wide}" table:number-columns-repeated="1000"/></table:table-row>"#,
|
||||
MAX_COLS - 1
|
||||
));
|
||||
let (_, rows) = read_rows(&book, Pick::Index(0)).unwrap();
|
||||
assert_eq!(rows.len(), full + 2);
|
||||
assert_eq!(rows.last(), Some(&vec![wide]));
|
||||
assert_eq!(rows.iter().map(Vec::len).sum::<usize>(), MAX_CELLS);
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ const TABLE: u16 = 0x0236;
|
||||
|
||||
/// Whether `bytes` is a compound file holding an Excel workbook stream.
|
||||
pub fn is_xls(bytes: &[u8]) -> bool {
|
||||
if !bytes.starts_with(&MAGIC) {
|
||||
if !bytes.starts_with(&MAGIC) || wordcraft_doc::cfb_guard::check(bytes).is_err() {
|
||||
return false;
|
||||
}
|
||||
let Ok(comp) = cfb::CompoundFile::open(Cursor::new(bytes)) else { return false };
|
||||
@@ -68,6 +68,7 @@ pub fn read_rows(bytes: &[u8], pick: super::Pick<'_>) -> Result<(Vec<String>, Ve
|
||||
}
|
||||
|
||||
fn workbook_stream(bytes: &[u8]) -> Result<Vec<u8>, String> {
|
||||
wordcraft_doc::cfb_guard::check(bytes).map_err(|e| format!("not a valid Excel 97–2003 workbook ({e})"))?;
|
||||
let mut comp = cfb::CompoundFile::open(Cursor::new(bytes)).map_err(|e| format!("not a valid Excel 97–2003 workbook ({e})"))?;
|
||||
if !comp.is_stream("/Workbook") {
|
||||
if comp.is_stream("/Book") {
|
||||
@@ -540,6 +541,59 @@ mod tests {
|
||||
r.iter().map(|r| r.iter().map(|c| c.to_string()).collect()).collect()
|
||||
}
|
||||
|
||||
/// #3642: a DIFAT naming one FAT sector thousands of times is refused before `cfb` builds
|
||||
/// a FAT far larger than the file.
|
||||
#[test]
|
||||
fn a_repeating_difat_is_refused_before_cfb_opens_it() {
|
||||
let f = wordcraft_doc::cfb_guard::repeating_difat_sample();
|
||||
assert!(!is_xls(&f));
|
||||
assert!(sheet_names(&f).is_err_and(|e| e.contains("FAT sectors in a file of")));
|
||||
}
|
||||
|
||||
/// #3642 review: valid workbooks pass the guard and open. The repository keeps no files
|
||||
/// Excel made, so these are written by the `cfb` crate in the shape of Excel 97–2003 ones:
|
||||
/// the Workbook stream [`build`] writes with the summary streams Excel adds, in 512-byte
|
||||
/// sectors (version 3, Excel's own) and 4096-byte ones (version 4); and one with an 8 MB
|
||||
/// stream beside it, past what the header's 109 FAT sector slots map in 512-byte sectors, so
|
||||
/// its FAT sectors are listed in a DIFAT chain too, which the guard walks.
|
||||
#[test]
|
||||
fn valid_xls_shaped_compound_files_pass_the_guard_and_open() {
|
||||
use std::io::{Read, Write};
|
||||
let book = build(&[("Sheet1", rows(&[&["a", "1"], &["b", "2"]]))]).unwrap();
|
||||
let mut workbook = Vec::new();
|
||||
cfb::CompoundFile::open(Cursor::new(book.as_slice())).unwrap().open_stream("/Workbook").unwrap().read_to_end(&mut workbook).unwrap();
|
||||
let file = |version: cfb::Version, streams: &[(&str, &[u8])]| {
|
||||
let mut comp = cfb::CompoundFile::create_with_version(version, Cursor::new(Vec::new())).unwrap();
|
||||
for (name, data) in streams {
|
||||
comp.create_stream(format!("/{name}")).unwrap().write_all(data).unwrap();
|
||||
}
|
||||
comp.into_inner().into_inner()
|
||||
};
|
||||
let summary = vec![0xFEu8; 4096];
|
||||
let big: Vec<u8> = (0..8usize << 20).map(|i| (i % 251) as u8).collect();
|
||||
let files = [
|
||||
("as built", book.clone()),
|
||||
(
|
||||
"version 3",
|
||||
file(
|
||||
cfb::Version::V3,
|
||||
&[("Workbook", &workbook), ("\u{5}SummaryInformation", &summary), ("\u{5}DocumentSummaryInformation", &summary)],
|
||||
),
|
||||
),
|
||||
("version 4", file(cfb::Version::V4, &[("Workbook", &workbook), ("\u{5}SummaryInformation", &summary)])),
|
||||
("with a DIFAT chain", file(cfb::Version::V3, &[("Workbook", &workbook), ("Ctls", &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}");
|
||||
assert!(is_xls(f), "{what}");
|
||||
assert_eq!(sheet_names(f).unwrap(), vec!["Sheet1"], "{what}");
|
||||
let (_, r) = read_rows(f, Pick::Index(0)).unwrap();
|
||||
assert_eq!(r, rows(&[&["a", "1"], &["b", "2"]]), "{what}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_a_biff8_workbook() {
|
||||
let book = build(&[
|
||||
|
||||
@@ -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())))
|
||||
}
|
||||
+132
-5
@@ -748,8 +748,11 @@ fn code_spans_and_links_around_backticks_read_as_before() {
|
||||
// inside a code span in link text, and link text with a backtick that nothing closes.
|
||||
let flow = crate::markdown::parse("\\``e` `` a ` b `` then ``` and `c` [x `]` y](http://a.org) [p ` q](http://b.org) ``f");
|
||||
let FBlock::Para(p) = &flow.blocks[0] else { panic!("{:?}", flow.blocks) };
|
||||
let spans: Vec<(String, bool, Option<String>)> =
|
||||
p.inlines.iter().filter_map(|i| if let model::Inline::Text(t, f) = i { Some((t.clone(), f.code, f.link.clone())) } else { None }).collect();
|
||||
let spans: Vec<(String, bool, Option<String>)> = p
|
||||
.inlines
|
||||
.iter()
|
||||
.filter_map(|i| if let model::Inline::Text(t, f) = i { Some((t.clone(), f.code, f.link.as_deref().map(str::to_string))) } else { None })
|
||||
.collect();
|
||||
let code: Vec<&str> = spans.iter().filter(|s| s.1).map(|s| s.0.as_str()).collect();
|
||||
assert_eq!(code, ["e", "a ` b", "c", "]"], "{spans:?}");
|
||||
let linked = |url: &str| spans.iter().filter(|s| s.2.as_deref() == Some(url)).map(|s| s.0.as_str()).collect::<String>();
|
||||
@@ -766,7 +769,7 @@ fn latex_href_keeps_ampersand() {
|
||||
.iter()
|
||||
.filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None })
|
||||
.flat_map(|p| p.inlines.iter())
|
||||
.filter_map(|i| if let model::Inline::Text(_, f) = i { Some(f.link.clone()) } else { None })
|
||||
.filter_map(|i| if let model::Inline::Text(_, f) = i { Some(f.link.as_deref().map(str::to_string)) } else { None })
|
||||
.collect();
|
||||
assert_eq!(links, [Some("https://e.com/a?b=1&c=2".to_string())]);
|
||||
|
||||
@@ -775,7 +778,7 @@ fn latex_href_keeps_ampersand() {
|
||||
.iter()
|
||||
.filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None })
|
||||
.flat_map(|p| p.inlines.iter())
|
||||
.filter_map(|i| if let model::Inline::Text(t, f) = i { Some((t.to_string(), f.link.clone())) } else { None })
|
||||
.filter_map(|i| if let model::Inline::Text(t, f) = i { Some((t.to_string(), f.link.as_deref().map(str::to_string))) } else { None })
|
||||
.collect();
|
||||
let url = "https://e.com/a?b=1&c=2".to_string();
|
||||
assert_eq!(texts, [(url.clone(), Some(url))]);
|
||||
@@ -789,7 +792,8 @@ fn brackets_do_not_turn_later_links_into_text() {
|
||||
let links = |md: &str| {
|
||||
let flow = crate::markdown::parse(md);
|
||||
let FBlock::Para(p) = &flow.blocks[0] else { panic!("{:?}", flow.blocks) };
|
||||
let mut urls: Vec<String> = p.inlines.iter().filter_map(|i| if let model::Inline::Text(_, f) = i { f.link.clone() } else { None }).collect();
|
||||
let mut urls: Vec<String> =
|
||||
p.inlines.iter().filter_map(|i| if let model::Inline::Text(_, f) = i { f.link.as_deref().map(str::to_string) } else { None }).collect();
|
||||
urls.dedup();
|
||||
urls
|
||||
};
|
||||
@@ -863,6 +867,21 @@ fn exported_links_keep_only_safe_targets() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A link target with any whitespace (a no-break or ideographic space too) is written in `<…>`,
|
||||
/// so the reader, which ends a bare destination at the first whitespace, reads it back whole.
|
||||
#[test]
|
||||
fn markdown_link_targets_with_unicode_spaces_read_back() {
|
||||
for l in ["https://e.com/a\u{a0}b", "https://e.com/a\u{3000}b", "https://e.com/a b"] {
|
||||
let mut d = Document::new();
|
||||
let mut p = Paragraph::with_text("", CharProps::default());
|
||||
p.insert_text(0, "go", &CharProps { link: Some(l.to_string()), ..Default::default() }).unwrap();
|
||||
d.body = vec![para_block(p)];
|
||||
let md = String::from_utf8(export("md", &d).unwrap().unwrap()).unwrap();
|
||||
let sp = spans(&crate::markdown::parse(&md));
|
||||
assert_eq!(sp.iter().find(|(t, _)| t == "go").and_then(|(_, f)| f.link.as_deref()), Some(l), "md: {md}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_drawings_are_left_out_of_exports() {
|
||||
let mut d = wordcraft_doc::Document::from_text("x");
|
||||
@@ -875,3 +894,111 @@ fn hidden_drawings_are_left_out_of_exports() {
|
||||
// Two pictures, one hidden: one comes out.
|
||||
assert_eq!(crate::html::export(&d).matches("<img").count(), 1);
|
||||
}
|
||||
|
||||
/// Every text span of the flow, with its formatting.
|
||||
fn spans(flow: &model::Flow) -> Vec<(String, model::Fmt)> {
|
||||
flow.blocks
|
||||
.iter()
|
||||
.filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None })
|
||||
.flat_map(|p| p.inlines.iter())
|
||||
.filter_map(|i| if let model::Inline::Text(t, f) = i { Some((t.clone(), f.clone())) } else { None })
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A font name or link target too long for any real one is dropped, its text kept: every run
|
||||
/// carried its own copy, so one long name times many runs was gigabytes (#3713).
|
||||
#[test]
|
||||
fn overlong_font_names_and_links_are_dropped() {
|
||||
let long = "x".repeat(1 << 20);
|
||||
let rtf = format!("{{\\rtf1{{\\fonttbl{{\\f0 Arial;}}{{\\f1 {long};}}}}\\f1 {}}}", "{\\b a}a".repeat(1000));
|
||||
let flow = crate::rtf::parse(rtf.as_bytes()).unwrap();
|
||||
let sp = spans(&flow);
|
||||
assert!(sp.iter().map(|s| s.0.len()).sum::<usize>() >= 2000);
|
||||
assert!(sp.iter().all(|(_, f)| f.font.is_none()), "an overlong RTF font name is no font");
|
||||
// A real one still is.
|
||||
let flow = crate::rtf::parse(b"{\\rtf1{\\fonttbl{\\f0 Arial;}{\\f1 Georgia;}}\\f1 a}").unwrap();
|
||||
assert_eq!(spans(&flow)[0].1.font.as_deref(), Some("Georgia"));
|
||||
|
||||
let html =
|
||||
format!("<p><span style=\"font-family:'{long}'\"><a href=\"https://e.com/{long}\">kept</a></span> <font face=\"{long}\">too</font></p>");
|
||||
let sp = spans(&crate::html::parse(&html));
|
||||
assert!(sp.iter().any(|(t, _)| t.contains("kept")) && sp.iter().any(|(t, _)| t.contains("too")));
|
||||
assert!(sp.iter().all(|(_, f)| f.font.is_none() && f.link.is_none()), "{:?}", sp.iter().map(|s| &s.0).collect::<Vec<_>>());
|
||||
|
||||
let sp = spans(&crate::markdown::parse(&format!("[kept](https://e.com/{long})")));
|
||||
assert_eq!(sp.len(), 1);
|
||||
assert_eq!((sp[0].0.as_str(), sp[0].1.link.is_none()), ("kept", true));
|
||||
}
|
||||
|
||||
/// Spans of one link share its target, and so do references to one definition: a long
|
||||
/// definition and many references no longer copy the destination into each (#3713).
|
||||
#[test]
|
||||
fn spans_share_their_link_target() {
|
||||
let dest = format!("https://e.com/{}", "x".repeat(4000));
|
||||
let md = format!("{}\n\n[r]: {dest}\n", "[a][r] **[b][r]** ".repeat(10_000));
|
||||
let sp = spans(&crate::markdown::parse(&md));
|
||||
let links: Vec<_> = sp.iter().filter_map(|(_, f)| f.link.as_ref()).collect();
|
||||
assert_eq!(links.len(), 20_000);
|
||||
assert!(links.iter().all(|l| Arc::ptr_eq(l, links[0])), "one copy of the destination");
|
||||
|
||||
let html = format!("<a href=\"{dest}\">{}</a>", "<b>a</b>a".repeat(1000));
|
||||
let sp = spans(&crate::html::parse(&html));
|
||||
assert!(sp.len() >= 2000);
|
||||
let first = sp[0].1.link.clone().unwrap();
|
||||
assert!(sp.iter().all(|(_, f)| f.link.as_ref().is_some_and(|l| Arc::ptr_eq(l, &first))));
|
||||
}
|
||||
|
||||
/// The document's runs own their link and font strings; past a budget further runs keep their
|
||||
/// text but lose them, so a flow of shared names can't expand into gigabytes (#3713).
|
||||
#[test]
|
||||
fn run_link_and_font_copies_are_budgeted() {
|
||||
let link: Arc<str> = format!("https://e.com/{}", "x".repeat(model::MAX_LINK - 20)).into();
|
||||
let mut p = model::Para::default();
|
||||
for i in 0..20_000 {
|
||||
let f = model::Fmt { bold: i % 2 == 0, link: Some(link.clone()), ..Default::default() };
|
||||
p.push_text("a", &f);
|
||||
}
|
||||
model::begin_import();
|
||||
let (doc, warned) = crate::test_log::warnings(|| model::to_doc(&model::Flow { blocks: vec![FBlock::Para(p)], ..Default::default() }));
|
||||
let paras: Vec<_> = doc.body.iter().filter_map(|b| if let Block::Para(p) = &**b { Some(p) } else { None }).collect();
|
||||
assert_eq!(paras.iter().map(|p| p.text.len()).sum::<usize>(), 20_000, "all text kept");
|
||||
let runs: Vec<_> = paras.iter().flat_map(|p| p.runs.iter()).collect();
|
||||
let kept: usize = runs.iter().filter_map(|r| r.props.link.as_ref()).map(String::len).sum();
|
||||
assert!(kept > 0 && kept <= 64 << 20, "{kept}");
|
||||
// The runs that lose theirs are said to, once for the document (review of #3873).
|
||||
assert_eq!(warned.iter().filter(|w| w.contains("later runs lose them")).count(), 1, "{warned:?}");
|
||||
}
|
||||
|
||||
/// Every limit that leaves a font name, link or style name out says so, once per kind per
|
||||
/// document rather than once per item (review of #3873; the tracker complains of silent loss).
|
||||
#[test]
|
||||
fn dropped_names_and_links_are_logged_once_per_document() {
|
||||
let long = "x".repeat(model::MAX_LINK + 1);
|
||||
let html = format!(
|
||||
"<p><span style=\"font-family:'{long}'\"><a href=\"https://e.com/{long}\">a</a></span> <font face=\"{long}\">b</font> <a href=\"https://e.com/{long}\">c</a></p>"
|
||||
);
|
||||
let count = |w: &[String], what: &str| w.iter().filter(|l| l.contains(what)).count();
|
||||
for _ in 0..2 {
|
||||
// Each import is its own document: the second is reported too.
|
||||
let (doc, warned) = crate::test_log::warnings(|| crate::html::import(&html));
|
||||
assert!(doc.body.len() == 1, "the text is kept");
|
||||
assert_eq!(count(&warned, "font names longer"), 1, "{warned:?}");
|
||||
assert_eq!(count(&warned, "links longer"), 1, "{warned:?}");
|
||||
}
|
||||
let (_, warned) = crate::test_log::warnings(|| crate::html::import("<p><a href=\"https://e.com\" style=\"font-family:Georgia\">fine</a></p>"));
|
||||
assert!(warned.is_empty(), "nothing left out, nothing said: {warned:?}");
|
||||
|
||||
let md = format!("[a](https://e.com/{long}) [b](https://e.com/{long})");
|
||||
let (_, warned) = crate::test_log::warnings(|| crate::markdown::import(&md));
|
||||
assert_eq!(count(&warned, "links longer"), 1, "{warned:?}");
|
||||
|
||||
let rtf =
|
||||
format!("{{\\rtf1{{\\fonttbl{{\\f0 Arial;}}{{\\f1 {long};}}{{\\f2 {long};}}}}{{\\stylesheet{{\\s1 {long};}}{{\\s2 {long};}}}}\\f1 a\\f2 b}}");
|
||||
let (_, warned) = crate::test_log::warnings(|| crate::rtf::import(rtf.as_bytes()).unwrap());
|
||||
assert_eq!(count(&warned, "font names longer"), 1, "{warned:?}");
|
||||
assert_eq!(count(&warned, "style names longer"), 1, "{warned:?}");
|
||||
|
||||
let tex = format!("\\href{{https://e.com/{long}}}{{a}} \\url{{https://e.com/{long}}}");
|
||||
let (_, warned) = crate::test_log::warnings(|| crate::latex::import(tex.as_bytes()));
|
||||
assert_eq!(count(&warned, "links longer"), 1, "{warned:?}");
|
||||
}
|
||||
@@ -13,7 +13,7 @@ pub mod picture;
|
||||
mod region;
|
||||
pub use region::Region;
|
||||
mod pixels;
|
||||
pub use pixels::{MAX_DECODED_BYTES, MAX_RASTER_PIXELS, decode_fits, fit_pixels, rgba_bytes, rgba_fits};
|
||||
pub use pixels::{MAX_DECODED_BYTES, MAX_RASTER_PIXELS, decode_fits, decode_peak_fits, fit_pixels, rgba_bytes, rgba_fits};
|
||||
|
||||
/// Points per inch.
|
||||
pub const PT_PER_IN: f32 = 72.0;
|
||||
|
||||
@@ -18,9 +18,18 @@ pub fn rgba_fits(w: u32, h: u32, budget: u64) -> bool {
|
||||
}
|
||||
|
||||
/// Whether decoding a `w`×`h` picture fits `budget` bytes at its peak: the decoder's buffer and
|
||||
/// the RGBA conversion (or its premultiplied copy) are alive together, so two RGBA buffers.
|
||||
/// the RGBA conversion (or its premultiplied copy) are alive together, so two RGBA buffers. For a
|
||||
/// decoder whose buffer isn't four bytes a pixel, see [`decode_peak_fits`].
|
||||
pub fn decode_fits(w: u32, h: u32, budget: u64) -> bool {
|
||||
w > 0 && h > 0 && rgba_bytes(w, h).and_then(|b| b.checked_mul(2)).is_some_and(|b| b <= budget)
|
||||
decode_peak_fits(w, h, 4, budget)
|
||||
}
|
||||
|
||||
/// [`decode_fits`] for a decoder that hands back `native` bytes a pixel (8 for 16-bit RGBA, 16 for
|
||||
/// float RGBA): its buffer lives alongside the RGBA8 conversion, then the conversion alongside its
|
||||
/// premultiplied copy, so the peak is the larger of `native + 4` and `8` bytes a pixel.
|
||||
pub fn decode_peak_fits(w: u32, h: u32, native: u64, budget: u64) -> bool {
|
||||
let per_pixel = native.saturating_add(4).max(8);
|
||||
w > 0 && h > 0 && (w as u64).checked_mul(h as u64).and_then(|p| p.checked_mul(per_pixel)).is_some_and(|b| b <= budget)
|
||||
}
|
||||
|
||||
/// `w`×`h` shrunk (aspect kept, each side at least 1) until it holds at most `max_pixels`.
|
||||
@@ -53,6 +62,18 @@ mod tests {
|
||||
assert!(!decode_fits(0, 8, u64::MAX));
|
||||
}
|
||||
|
||||
/// A 16-bit RGBA decode (8 bytes a pixel) lives alongside its 4-byte RGBA8 conversion: 12 bytes
|
||||
/// a pixel at the peak, not the 8 two RGBA8 buffers take.
|
||||
#[test]
|
||||
fn wide_decoder_buffers_are_charged_at_their_size() {
|
||||
assert!(decode_peak_fits(16, 8, 4, 1024), "RGBA8: two RGBA8 buffers");
|
||||
assert!(decode_peak_fits(16, 8, 1, 1024), "grey: the premultiplied copy is the peak");
|
||||
assert!(!decode_peak_fits(16, 8, 8, 1024), "RGBA16 needs 1536 bytes");
|
||||
assert!(decode_peak_fits(16, 8, 8, 1536));
|
||||
assert!(!decode_peak_fits(16, 8, 16, 2559), "RGBA32F: 20 bytes a pixel");
|
||||
assert!(!decode_peak_fits(u32::MAX, u32::MAX, u64::MAX, u64::MAX), "overflow is refused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_pixels_keeps_normal_sizes_and_shrinks_huge_ones() {
|
||||
assert_eq!(fit_pixels(612, 792, MAX_RASTER_PIXELS), (612, 792));
|
||||
|
||||
@@ -20,7 +20,7 @@ use wordcraft_doc::props::{Border, BorderStyle, CharProps, Rgb, TextColor};
|
||||
use wordcraft_doc::{Document, Paragraph, Pos, StoryRef};
|
||||
use wordcraft_geom::Rect;
|
||||
|
||||
use crate::display::revision_color;
|
||||
use crate::display::{author_order, revision_color};
|
||||
use crate::{BoxContext, BoxLayout, Ctx, DocLayout, Edges, Placed, fit_box, layout_box, push_para};
|
||||
|
||||
/// Width of the markup area beside each page (points): the editor's.
|
||||
@@ -103,17 +103,6 @@ pub fn replies(doc: &Document) -> BTreeMap<u32, Vec<u32>> {
|
||||
m
|
||||
}
|
||||
|
||||
/// The comment authors in order of first appearance (for their colours).
|
||||
fn author_index(doc: &Document, author: &str) -> u32 {
|
||||
let mut seen: Vec<&str> = Vec::new();
|
||||
for c in doc.comments.values() {
|
||||
if !seen.contains(&c.author.as_str()) {
|
||||
seen.push(&c.author);
|
||||
}
|
||||
}
|
||||
seen.iter().position(|a| *a == author).unwrap_or(0) as u32
|
||||
}
|
||||
|
||||
fn author_name(a: &str) -> &str {
|
||||
if a.trim().is_empty() { "Author" } else { a }
|
||||
}
|
||||
@@ -132,10 +121,12 @@ pub(crate) fn add(ctx: &mut Ctx, lay: &mut DocLayout) {
|
||||
let card_w = MARKUP_AREA - 2.0 * SIDE;
|
||||
let inner = card_w - STRIPE - 2.0 * PAD;
|
||||
let mut cards: Vec<Card> = Vec::new();
|
||||
// The comment authors in order of first appearance (for their colours), once for every card.
|
||||
let authors = author_order(doc.comments.values().map(|c| c.author.as_str()));
|
||||
for (id, pos) in anchors {
|
||||
let Some(caret) = lay.caret_on(&pos, 0) else { continue };
|
||||
let Some(c) = doc.comments.get(&id) else { continue };
|
||||
let color = revision_color(author_index(doc, &c.author));
|
||||
let color = revision_color(authors.get(c.author.as_str()).copied().unwrap_or(0));
|
||||
let mut thread = vec![id];
|
||||
thread.extend(threads.get(&id).into_iter().flatten().copied());
|
||||
let x = STRIPE + PAD;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! A [`Contour`] is built once per placed object; each line then asks it which stretches of
|
||||
//! the line's band it takes ([`Contour::occupied`]), a walk over its edges.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -164,21 +165,103 @@ pub fn contour_area(r: Rect, float: &Float, outline: &[(f32, f32)]) -> Option<(R
|
||||
/// Largest picture (each side, pixels) whose transparency is looked at.
|
||||
const MAX_PICTURE_SIDE: u32 = 8192;
|
||||
|
||||
/// The outline of a picture's opaque pixels within `crop` (left, top, right, bottom fractions),
|
||||
/// in unit coordinates of the cropped picture. `None` for pictures without transparency (or
|
||||
/// that don't decode): their rectangle is their outline.
|
||||
pub fn picture_outline(bytes: &[u8], crop: [f32; 4]) -> Option<Vec<(f32, f32)>> {
|
||||
let pic = wordcraft_geom::picture::probe(bytes).ok()?;
|
||||
/// Most picture pixels decoded for their transparency in one layout. Every picture a document
|
||||
/// wraps tightly is decoded in full whatever its crop; past this, pictures wrap as their
|
||||
/// rectangles. Room for four pictures of the largest size looked at, or hundreds of photos.
|
||||
pub(crate) const MAX_DECODED_PIXELS: u64 = 1 << 28;
|
||||
|
||||
/// A picture's transparency: one alpha byte per pixel, row by row.
|
||||
struct Alpha {
|
||||
w: u32,
|
||||
h: u32,
|
||||
a: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Alpha {
|
||||
fn opaque(&self, x: u32, y: u32) -> bool {
|
||||
x < self.w
|
||||
&& (y as usize)
|
||||
.checked_mul(self.w as usize)
|
||||
.and_then(|r| r.checked_add(x as usize))
|
||||
.and_then(|i| self.a.get(i))
|
||||
.is_some_and(|a| *a >= 128)
|
||||
}
|
||||
}
|
||||
|
||||
/// A picture whose transparency would take its layout past [`MAX_DECODED_PIXELS`]: it wraps
|
||||
/// as its rectangle in this layout only.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct OverBudget;
|
||||
|
||||
/// Pictures' transparency decoded during one layout. A picture shown with many crops (each an
|
||||
/// outline of its own) is decoded once, however its crops interleave with other pictures', and
|
||||
/// decoding stops at [`MAX_DECODED_PIXELS`] (which also bounds what is kept).
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Alphas {
|
||||
/// Pictures decoded so far, by their bytes (address and length): their transparency (`None`: none).
|
||||
///
|
||||
/// The address tells pictures apart only while their bytes can't be freed and the address
|
||||
/// reused for other bytes of the same length: an `Alphas` lives for one layout pass (in its
|
||||
/// `Ctx`), and the document whose media hold the bytes is borrowed for the whole pass, so no
|
||||
/// picture's bytes are freed while it's kept. Kept any longer, it would need to hold on to
|
||||
/// the bytes, as [`crate::LayoutCache`]'s outlines do.
|
||||
seen: HashMap<(usize, usize), Option<Arc<Alpha>>>,
|
||||
/// Pictures refused for the budget (bytes' address and length), not looked at again.
|
||||
refused: HashSet<(usize, usize)>,
|
||||
/// Pixels decoded so far.
|
||||
pub(crate) decoded: u64,
|
||||
}
|
||||
|
||||
impl Alphas {
|
||||
/// The outline of picture `bytes`'s opaque pixels within `crop` (left, top, right, bottom
|
||||
/// fractions), in unit coordinates of the cropped picture. `None` for pictures without
|
||||
/// transparency (or that don't decode), and [`OverBudget`] past the budget: their rectangle
|
||||
/// is their outline.
|
||||
pub(crate) fn outline(&mut self, bytes: &[u8], crop: [f32; 4]) -> Result<Option<Vec<(f32, f32)>>, OverBudget> {
|
||||
let key = (bytes.as_ptr() as usize, bytes.len());
|
||||
let alpha = match self.seen.get(&key) {
|
||||
Some(alpha) => alpha.clone(),
|
||||
None if self.refused.contains(&key) => return Err(OverBudget),
|
||||
None => {
|
||||
let alpha = decode_alpha(bytes, &mut self.decoded).inspect_err(|_| {
|
||||
self.refused.insert(key);
|
||||
})?;
|
||||
let alpha = alpha.map(Arc::new);
|
||||
self.seen.insert(key, alpha.clone());
|
||||
alpha
|
||||
}
|
||||
};
|
||||
Ok(alpha.and_then(|a| alpha_in_crop(&a, crop)))
|
||||
}
|
||||
}
|
||||
|
||||
/// The transparency of picture `bytes`, charging its pixels to `decoded`. `None` for pictures
|
||||
/// without transparency or that don't decode; [`OverBudget`] for one that would take `decoded`
|
||||
/// past [`MAX_DECODED_PIXELS`]. Pictures whose header says they're opaque cost nothing.
|
||||
fn decode_alpha(bytes: &[u8], decoded: &mut u64) -> Result<Option<Alpha>, OverBudget> {
|
||||
use image::ImageDecoder;
|
||||
let Ok(pic) = wordcraft_geom::picture::probe(bytes) else { return Ok(None) };
|
||||
// Formats that can't be transparent aren't decoded.
|
||||
if pic.format == image::ImageFormat::Jpeg || pic.width > MAX_PICTURE_SIDE || pic.height > MAX_PICTURE_SIDE {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
let img = pic.decode(wordcraft_geom::MAX_DECODED_BYTES).ok()?;
|
||||
if !img.color().has_alpha() {
|
||||
return None;
|
||||
// Only the header is read here (a PNG's `tRNS` counts as transparency).
|
||||
let has_alpha =
|
||||
image::ImageReader::with_format(std::io::Cursor::new(bytes), pic.format).into_decoder().ok().is_some_and(|d| d.color_type().has_alpha());
|
||||
if !has_alpha {
|
||||
return Ok(None);
|
||||
}
|
||||
let pixels = u64::from(pic.width) * u64::from(pic.height);
|
||||
*decoded = decoded.checked_add(pixels).filter(|t| *t <= MAX_DECODED_PIXELS).ok_or(OverBudget)?;
|
||||
let Ok(img) = pic.decode(wordcraft_geom::MAX_DECODED_BYTES) else { return Ok(None) };
|
||||
let img = img.into_rgba8();
|
||||
let (iw, ih) = img.dimensions();
|
||||
let (w, h) = img.dimensions();
|
||||
Ok(Some(Alpha { w, h, a: img.pixels().map(|p| p.0[3]).collect() }))
|
||||
}
|
||||
|
||||
/// The outline of `img`'s opaque pixels within `crop`: see [`Alphas::outline`].
|
||||
fn alpha_in_crop(img: &Alpha, crop: [f32; 4]) -> Option<Vec<(f32, f32)>> {
|
||||
let (iw, ih) = (img.w, img.h);
|
||||
// The part the renderers draw (#1899): a sliver keeps its partly covered pixels.
|
||||
let [cl, ct, vw, vh] = wordcraft_metafile::crop_window(crop);
|
||||
let (fw, fh) = (f64::from(iw), f64::from(ih));
|
||||
@@ -197,9 +280,7 @@ pub fn picture_outline(bytes: &[u8], crop: [f32; 4]) -> Option<Vec<(f32, f32)>>
|
||||
let opaque = |gx: usize, gy: usize| {
|
||||
let (px0, py0) = (x0 + gx as u32 * cell, y0 + gy as u32 * cell);
|
||||
let (px1, py1) = ((px0 + cell).min(x1), (py0 + cell).min(y1));
|
||||
(py0..py1)
|
||||
.step_by(step as usize)
|
||||
.any(|y| (px0..px1).step_by(step as usize).any(|x| img.get_pixel_checked(x, y).is_some_and(|p| p.0[3] >= 128)))
|
||||
(py0..py1).step_by(step as usize).any(|y| (px0..px1).step_by(step as usize).any(|x| img.opaque(x, y)))
|
||||
};
|
||||
let pts = alpha_outline(gw as usize, gh as usize, opaque)?;
|
||||
// Grid points onto the picture as the renderers draw the window, whose edges fall within
|
||||
@@ -214,6 +295,10 @@ pub fn picture_outline(bytes: &[u8], crop: [f32; 4]) -> Option<Vec<(f32, f32)>>
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn picture_outline(bytes: &[u8], crop: [f32; 4]) -> Option<Vec<(f32, f32)>> {
|
||||
Alphas::default().outline(bytes, crop).ok().flatten()
|
||||
}
|
||||
|
||||
fn diamond(through: bool) -> Contour {
|
||||
Contour::new(&[(50.0, 0.0), (100.0, 50.0), (50.0, 100.0), (0.0, 50.0)], 0.0, 0.0, 0.0, through).unwrap().1
|
||||
}
|
||||
|
||||
+190
-58
@@ -1,5 +1,7 @@
|
||||
//! Page → draw items, shared by the raster renderer, the PDF exporter and thumbnails.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind, PathSeg, TextAlign};
|
||||
use wordcraft_doc::para::{InlineObject, ShapeKind};
|
||||
use wordcraft_doc::props::{Border, BorderStyle, Rgb, TextColor, TextDirection, Underline};
|
||||
@@ -530,21 +532,53 @@ fn stroke_of(u: Underline) -> Stroke {
|
||||
pub fn page_display(doc: &Document, page: &Page, opts: &DisplayOptions) -> Vec<Draw> {
|
||||
let mut out = Vec::new();
|
||||
let (ha, ba) = dimming(opts);
|
||||
stories_display(doc, &[&page.header, &page.footer], opts, ha, &mut out);
|
||||
stories_display(doc, &[&page.items], opts, ba, &mut out);
|
||||
body_change_bars(doc, page, opts, ba, &mut out);
|
||||
let authors = Authors::new(doc);
|
||||
stories_display(doc, &[&page.header[..], &page.footer[..]], None, opts, &authors, ha, &mut out);
|
||||
stories_display(doc, &[&page.items[..]], None, opts, &authors, ba, &mut out);
|
||||
body_change_bars(doc, page, &page.items, opts, ba, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// A page's draw items one placed item at a time, for a caller that draws and tags each item on
|
||||
/// its own (the PDF export). What every item shares, the revision authors' colours and the cells
|
||||
/// cut off at their edge, is worked out once for the page, not once for every item as calling
|
||||
/// [`page_display`] with each would (#3866).
|
||||
pub struct ItemDisplay<'a> {
|
||||
doc: &'a Document,
|
||||
page: &'a Page,
|
||||
authors: Authors<'a>,
|
||||
clips: Clips,
|
||||
}
|
||||
|
||||
impl<'a> ItemDisplay<'a> {
|
||||
pub fn new(doc: &'a Document, page: &'a Page) -> Self {
|
||||
let cells: Vec<Placed> =
|
||||
page.header.iter().chain(&page.footer).chain(&page.items).filter(|c| matches!(c, Placed::Cell { clip: true, .. })).cloned().collect();
|
||||
ItemDisplay { doc, page, authors: Authors::new(doc), clips: clipped_cells(&cells) }
|
||||
}
|
||||
|
||||
/// The draw items of `it` alone, as [`page_display`] gives them for a page holding just it
|
||||
/// (and, for a line in a clipped cell, the cell's clip).
|
||||
pub fn draws(&self, it: &Placed, opts: &DisplayOptions) -> Vec<Draw> {
|
||||
let mut out = Vec::new();
|
||||
let items = std::slice::from_ref(it);
|
||||
let (_, ba) = dimming(opts);
|
||||
stories_display(self.doc, &[items], Some(&self.clips), opts, &self.authors, ba, &mut out);
|
||||
body_change_bars(self.doc, self.page, items, opts, ba, &mut out);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// A page's draw items as two layers, its headers and footers then its body, each at full
|
||||
/// strength with the opacity to draw it at. A dimmed layer is faded as one picture, as Word dims
|
||||
/// it: where its lines or fills overlap (a table's borders meeting) they don't darken each other.
|
||||
pub fn page_layers(doc: &Document, page: &Page, opts: &DisplayOptions) -> [(f32, Vec<Draw>); 2] {
|
||||
let (ha, ba) = dimming(opts);
|
||||
let (mut hf, mut body) = (Vec::new(), Vec::new());
|
||||
stories_display(doc, &[&page.header, &page.footer], opts, 1.0, &mut hf);
|
||||
stories_display(doc, &[&page.items], opts, 1.0, &mut body);
|
||||
body_change_bars(doc, page, opts, 1.0, &mut body);
|
||||
let authors = Authors::new(doc);
|
||||
stories_display(doc, &[&page.header[..], &page.footer[..]], None, opts, &authors, 1.0, &mut hf);
|
||||
stories_display(doc, &[&page.items[..]], None, opts, &authors, 1.0, &mut body);
|
||||
body_change_bars(doc, page, &page.items, opts, 1.0, &mut body);
|
||||
[(ha, hf), (ba, body)]
|
||||
}
|
||||
|
||||
@@ -554,18 +588,34 @@ fn dimming(opts: &DisplayOptions) -> (f32, f32) {
|
||||
(a(opts.dim_header), a(opts.dim_body))
|
||||
}
|
||||
|
||||
fn body_change_bars(doc: &Document, page: &Page, opts: &DisplayOptions, alpha: f32, out: &mut Vec<Draw>) {
|
||||
fn body_change_bars(doc: &Document, page: &Page, items: &[Placed], opts: &DisplayOptions, alpha: f32, out: &mut Vec<Draw>) {
|
||||
if opts.markup && (opts.revisions.insertions_deletions || opts.revisions.formatting) && opts.revisions.changed_lines != ChangeBar::None {
|
||||
change_bars(doc, page, &opts.revisions, alpha, out);
|
||||
change_bars(doc, page, items, &opts.revisions, alpha, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw items for the placed items of `stories`, faded by `a`.
|
||||
fn stories_display(doc: &Document, stories: &[&Vec<Placed>], opts: &DisplayOptions, a: f32, out: &mut Vec<Draw>) {
|
||||
/// `shared` gives the page's clipped cells when the caller worked them out already.
|
||||
fn stories_display(
|
||||
doc: &Document,
|
||||
stories: &[&[Placed]],
|
||||
shared: Option<&Clips>,
|
||||
opts: &DisplayOptions,
|
||||
authors: &Authors,
|
||||
a: f32,
|
||||
out: &mut Vec<Draw>,
|
||||
) {
|
||||
let hidden = opts.hide_object.as_ref().map(|p| Hidden::new(doc, p));
|
||||
for items in stories {
|
||||
let warps = warped_boxes(doc, items);
|
||||
let clips = clipped_cells(items);
|
||||
let own;
|
||||
let clips = match shared {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
own = clipped_cells(items);
|
||||
&own
|
||||
}
|
||||
};
|
||||
let mut done: Vec<u32> = Vec::new();
|
||||
for it in items.iter() {
|
||||
// The object being dragged is drawn by its preview instead: leave it out here.
|
||||
@@ -573,10 +623,10 @@ fn stories_display(doc: &Document, stories: &[&Vec<Placed>], opts: &DisplayOptio
|
||||
continue;
|
||||
}
|
||||
if let Placed::Lines { story, path, .. } = it
|
||||
&& let Some(rect) = cell_clip(&clips, *story, &path.0)
|
||||
&& let Some(rect) = cell_clip(clips, *story, &path.0)
|
||||
{
|
||||
let mut inner = Vec::new();
|
||||
item(doc, it, opts, a, &mut inner);
|
||||
item(doc, it, opts, authors, a, &mut inner);
|
||||
out.push(Draw::Clip { rect, items: inner });
|
||||
continue;
|
||||
}
|
||||
@@ -590,10 +640,10 @@ fn stories_display(doc: &Document, stories: &[&Vec<Placed>], opts: &DisplayOptio
|
||||
if let Some((_, rect, warp)) = warps.iter().find(|(w, ..)| w == id) {
|
||||
let lines: Vec<&Placed> =
|
||||
items.iter().filter(|i| matches!(i, Placed::Lines { story: StoryRef::Part(s), .. } if s == id)).collect();
|
||||
warped_text(doc, &lines, *rect, warp, opts, a, out);
|
||||
warped_text(doc, &lines, *rect, warp, opts, authors, a, out);
|
||||
}
|
||||
}
|
||||
_ => item(doc, it, opts, a, out),
|
||||
_ => item(doc, it, opts, authors, a, out),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -606,35 +656,50 @@ pub fn object_display(doc: &Document, page: &Page, o: &InlineObject, rect: Rect,
|
||||
let mut out = Vec::new();
|
||||
object_draws(o, rect, Spin::default(), 1.0, opts, &mut out);
|
||||
let Some(id) = text_box else { return out };
|
||||
let authors = Authors::new(doc);
|
||||
for items in [&page.items, &page.header, &page.footer] {
|
||||
let lines: Vec<&Placed> = items.iter().filter(|i| matches!(i, Placed::Lines { story: StoryRef::Part(s), .. } if *s == id)).collect();
|
||||
match warped_boxes(doc, items).into_iter().find(|(w, ..)| *w == id) {
|
||||
Some((_, rect, warp)) if !lines.is_empty() => warped_text(doc, &lines, rect, &warp, opts, 1.0, &mut out),
|
||||
_ => lines.into_iter().for_each(|it| item(doc, it, opts, 1.0, &mut out)),
|
||||
Some((_, rect, warp)) if !lines.is_empty() => warped_text(doc, &lines, rect, &warp, opts, &authors, 1.0, &mut out),
|
||||
_ => lines.into_iter().for_each(|it| item(doc, it, opts, &authors, 1.0, &mut out)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The cells among `items` whose content is cut off at their edge (see [`Placed::Cell`]'s
|
||||
/// `clip`): their story, the path of their content (table, row, cell) and their area.
|
||||
fn clipped_cells(items: &[Placed]) -> Vec<(StoryRef, Vec<u32>, Rect)> {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
Placed::Cell { rect, table, row, cell, story, clip: true, .. } => {
|
||||
let mut prefix = table.0.clone();
|
||||
prefix.extend([u32::try_from(*row).ok()?, u32::try_from(*cell).ok()?]);
|
||||
Some((*story, prefix, *rect))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
/// `clip`), by story and the path of their content (table, row, cell): their area, and the
|
||||
/// order they come in (the first of two that hold a line clips it).
|
||||
type Clips = HashMap<StoryRef, HashMap<Vec<u32>, (usize, Rect)>>;
|
||||
|
||||
fn clipped_cells(items: &[Placed]) -> Clips {
|
||||
let mut clips = Clips::new();
|
||||
let cells = items.iter().filter_map(|it| match it {
|
||||
Placed::Cell { rect, table, row, cell, story, clip: true, .. } => {
|
||||
let mut prefix = table.0.clone();
|
||||
prefix.extend([u32::try_from(*row).ok()?, u32::try_from(*cell).ok()?]);
|
||||
Some((*story, prefix, *rect))
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
for (i, (story, prefix, rect)) in cells.enumerate() {
|
||||
crate::work::steps(1);
|
||||
clips.entry(story).or_default().entry(prefix).or_insert((i, rect));
|
||||
}
|
||||
clips
|
||||
}
|
||||
|
||||
/// The area to clip a line at `path` of `story` to: its cell's, when that cell is clipped.
|
||||
fn cell_clip(clips: &[(StoryRef, Vec<u32>, Rect)], story: StoryRef, path: &[u32]) -> Option<Rect> {
|
||||
clips.iter().find(|(s, prefix, _)| *s == story && path.starts_with(prefix)).map(|(_, _, r)| *r)
|
||||
/// The area to clip a line at `path` of `story` to: its cell's, when that cell is clipped. Only
|
||||
/// the cells holding the line are looked up, not every clipped cell on the page.
|
||||
fn cell_clip(clips: &Clips, story: StoryRef, path: &[u32]) -> Option<Rect> {
|
||||
let cells = clips.get(&story)?;
|
||||
(0..=path.len())
|
||||
.filter_map(|n| {
|
||||
crate::work::steps(1);
|
||||
cells.get(path.get(..n)?)
|
||||
})
|
||||
.min_by_key(|(i, _)| *i)
|
||||
.map(|(_, r)| *r)
|
||||
}
|
||||
|
||||
/// The text boxes among `items` whose text is warped (Text Effects › Transform, drawn bent): their
|
||||
@@ -661,12 +726,13 @@ fn warped_text(
|
||||
rect: Rect,
|
||||
warp: &wordcraft_doc::wordart::TextWarp,
|
||||
opts: &DisplayOptions,
|
||||
authors: &Authors,
|
||||
alpha: f32,
|
||||
out: &mut Vec<Draw>,
|
||||
) {
|
||||
let mut raw = Vec::new();
|
||||
for it in lines {
|
||||
item(doc, it, opts, alpha, &mut raw);
|
||||
item(doc, it, opts, authors, alpha, &mut raw);
|
||||
}
|
||||
// The letters: runs with effects as they are, plain runs from their glyphs.
|
||||
let mut arts: Vec<(BezPath, Box<wordcraft_doc::wordart::TextEffects>, Rgb, f32)> = Vec::new();
|
||||
@@ -706,7 +772,7 @@ pub const CHANGE_BAR: Rgb = Rgb(0x50, 0x50, 0x50);
|
||||
|
||||
/// Bars in the margin beside body lines with tracked insertions or deletions, or (when shown)
|
||||
/// tracked formatting changes of their text, paragraph or paragraph mark.
|
||||
fn change_bars(doc: &Document, page: &Page, m: &MarkupOptions, alpha: f32, out: &mut Vec<Draw>) {
|
||||
fn change_bars(doc: &Document, page: &Page, items: &[Placed], m: &MarkupOptions, alpha: f32, out: &mut Vec<Draw>) {
|
||||
// Outside: the left margin, or the outer one of a right-hand page with mirrored margins.
|
||||
let right = match m.changed_lines {
|
||||
ChangeBar::Right => true,
|
||||
@@ -715,7 +781,7 @@ fn change_bars(doc: &Document, page: &Page, m: &MarkupOptions, alpha: f32, out:
|
||||
};
|
||||
let bx = if right { page.body.right() + 9.0 } else { page.body.x - 9.0 };
|
||||
let color = m.changed_lines_color.unwrap_or(CHANGE_BAR);
|
||||
for it in &page.items {
|
||||
for it in items {
|
||||
let Placed::Lines { story, path, para: pl, l0, l1, y, turn, .. } = it else { continue };
|
||||
if turn.is_turned() {
|
||||
continue;
|
||||
@@ -788,13 +854,13 @@ fn char_box(b: &Border, x0: f32, x1: f32, top: f32, bottom: f32, alpha: f32, out
|
||||
out.push(rule(r, top, r, bottom, b, alpha));
|
||||
}
|
||||
|
||||
fn item(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, out: &mut Vec<Draw>) {
|
||||
item_in(doc, it, opts, alpha, Spin::default(), out)
|
||||
fn item(doc: &Document, it: &Placed, opts: &DisplayOptions, authors: &Authors, alpha: f32, out: &mut Vec<Draw>) {
|
||||
item_in(doc, it, opts, authors, alpha, Spin::default(), out)
|
||||
}
|
||||
|
||||
/// [`item`] drawn inside the turn `outer` (a turned Drawing Canvas's members): a shape's
|
||||
/// effects follow all the turns it is drawn inside.
|
||||
fn item_in(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, outer: Spin, out: &mut Vec<Draw>) {
|
||||
fn item_in(doc: &Document, it: &Placed, opts: &DisplayOptions, authors: &Authors, alpha: f32, outer: Spin, out: &mut Vec<Draw>) {
|
||||
match it {
|
||||
Placed::Fill { rect, color } => out.push(Draw::Fill { rect: *rect, color: *color, alpha, text_bg: false }),
|
||||
Placed::Rule { x0, y0, x1, y1, border } => out.push(rule(*x0, *y0, *x1, *y1, border, alpha)),
|
||||
@@ -814,14 +880,14 @@ fn item_in(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, outer
|
||||
Placed::Clip { rect, spin, items } => {
|
||||
let mut inner = Vec::new();
|
||||
for it in items {
|
||||
item_in(doc, it, opts, alpha, spin.within(outer), &mut inner);
|
||||
item_in(doc, it, opts, authors, alpha, spin.within(outer), &mut inner);
|
||||
}
|
||||
spun(*spin, *rect, vec![Draw::Clip { rect: *rect, items: inner }], out)
|
||||
}
|
||||
Placed::Cell { .. } | Placed::Object { .. } => {}
|
||||
Placed::Lines { story, path, para, l0, l1, x, y, turn } if turn.is_turned() => {
|
||||
let mut items = Vec::new();
|
||||
lines(doc, *story, path, para, *l0, *l1, 0.0, 0.0, opts, alpha, *turn == TextDirection::Down, &mut items);
|
||||
lines(doc, *story, path, para, *l0, *l1, 0.0, 0.0, opts, authors, alpha, *turn == TextDirection::Down, &mut items);
|
||||
// Link areas are axis-aligned page rectangles: turned text gets none.
|
||||
for d in &mut items {
|
||||
if let Draw::Glyphs { link, .. } = d {
|
||||
@@ -833,7 +899,7 @@ fn item_in(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, outer
|
||||
}
|
||||
out.push(Draw::Turned { x: *x, y: *y, turn: *turn, items });
|
||||
}
|
||||
Placed::Lines { story, path, para, l0, l1, x, y, .. } => lines(doc, *story, path, para, *l0, *l1, *x, *y, opts, alpha, false, out),
|
||||
Placed::Lines { story, path, para, l0, l1, x, y, .. } => lines(doc, *story, path, para, *l0, *l1, *x, *y, opts, authors, alpha, false, out),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -851,6 +917,7 @@ fn lines(
|
||||
x: f32,
|
||||
y: f32,
|
||||
opts: &DisplayOptions,
|
||||
authors: &Authors,
|
||||
alpha: f32,
|
||||
down: bool,
|
||||
out: &mut Vec<Draw>,
|
||||
@@ -1078,7 +1145,7 @@ fn lines(
|
||||
let shown = opts.markup && m.insertions_deletions;
|
||||
let del = rc.del.filter(|_| shown);
|
||||
let ins = rc.ins.filter(|_| shown && del.is_none());
|
||||
let author = |r: u32| revision_color(doc.revisions.get(r as usize).map(|v| author_index(doc, &v.author)).unwrap_or(0));
|
||||
let author = |r: u32| authors.color(r);
|
||||
// Moved text: the Moves marks and colours (by default green, double strikethrough
|
||||
// where it was, double underline where it went).
|
||||
let moved_to = doc.move_name(ins).is_some();
|
||||
@@ -1411,18 +1478,12 @@ fn lines(
|
||||
let mx = if line.rtl { ex - 1.0 - size * 0.6 } else { ex + 1.0 };
|
||||
// A tracked (inserted or deleted) paragraph mark is drawn in its author's colour.
|
||||
let m = &opts.revisions;
|
||||
let by_author = |r: u32| revision_color(doc.revisions.get(r as usize).map(|v| author_index(doc, &v.author)).unwrap_or(0));
|
||||
let by_author = |r: u32| authors.color(r);
|
||||
let color = para.filter(|_| opts.markup && m.insertions_deletions).and_then(|p| match (p.mark.ins, p.mark.del) {
|
||||
(_, Some(r)) if doc.move_name(Some(r)).is_some() => Some(m.move_from_color.unwrap_or_else(|| by_author(r))),
|
||||
(Some(r), None) if doc.move_name(Some(r)).is_some() => Some(m.move_to_color.unwrap_or_else(|| by_author(r))),
|
||||
(_, Some(r)) => Some(
|
||||
m.delete_color
|
||||
.unwrap_or_else(|| revision_color(doc.revisions.get(r as usize).map(|v| author_index(doc, &v.author)).unwrap_or(0))),
|
||||
),
|
||||
(Some(r), None) => Some(
|
||||
m.insert_color
|
||||
.unwrap_or_else(|| revision_color(doc.revisions.get(r as usize).map(|v| author_index(doc, &v.author)).unwrap_or(0))),
|
||||
),
|
||||
(_, Some(r)) => Some(m.delete_color.unwrap_or_else(|| by_author(r))),
|
||||
(Some(r), None) => Some(m.insert_color.unwrap_or_else(|| by_author(r))),
|
||||
_ => None,
|
||||
});
|
||||
// The last paragraph of a section ends in a section break instead of a plain ¶.
|
||||
@@ -1851,14 +1912,35 @@ fn text_glyphs(doc: &Document, r: Rect, it: &GraphicItem, alpha: f32, k: f32) ->
|
||||
})
|
||||
}
|
||||
|
||||
fn author_index(doc: &Document, author: &str) -> u32 {
|
||||
let mut seen: Vec<&str> = Vec::new();
|
||||
for r in &doc.revisions {
|
||||
if !seen.contains(&r.author.as_str()) {
|
||||
seen.push(&r.author);
|
||||
}
|
||||
/// Each author's place among `authors` in order of first appearance (which colour marks their
|
||||
/// changes and comments), found in one pass.
|
||||
pub(crate) fn author_order<'a>(authors: impl Iterator<Item = &'a str>) -> HashMap<&'a str, u32> {
|
||||
let mut seen = HashMap::new();
|
||||
for a in authors {
|
||||
crate::work::steps(1);
|
||||
let n = u32::try_from(seen.len()).unwrap_or(u32::MAX);
|
||||
seen.entry(a).or_insert(n);
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
/// The colours of the document's revision authors ([`author_order`]), worked out once for a
|
||||
/// drawing, when its first tracked change is drawn, not once for every change.
|
||||
struct Authors<'a> {
|
||||
doc: &'a Document,
|
||||
order: std::cell::OnceCell<HashMap<&'a str, u32>>,
|
||||
}
|
||||
|
||||
impl<'a> Authors<'a> {
|
||||
fn new(doc: &'a Document) -> Self {
|
||||
Authors { doc, order: std::cell::OnceCell::new() }
|
||||
}
|
||||
|
||||
/// The colour of revision `r`'s author.
|
||||
fn color(&self, r: u32) -> Rgb {
|
||||
let order = self.order.get_or_init(|| author_order(self.doc.revisions.iter().map(|v| v.author.as_str())));
|
||||
revision_color(self.doc.revisions.get(r as usize).and_then(|v| order.get(v.author.as_str()).copied()).unwrap_or(0))
|
||||
}
|
||||
seen.iter().position(|a| *a == author).unwrap_or(0) as u32
|
||||
}
|
||||
|
||||
/// How far a legacy check box's square sits inside the room it takes on its line, on every side.
|
||||
@@ -2106,3 +2188,53 @@ fn upright_east_asian(items: Vec<Draw>) -> Vec<Draw> {
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_clips {
|
||||
use super::*;
|
||||
use wordcraft_doc::Path as DocPath;
|
||||
|
||||
/// A cell whose area is told apart by `x`.
|
||||
fn cell(story: StoryRef, table: Vec<u32>, row: usize, cell: usize, clip: bool, x: f32) -> Placed {
|
||||
Placed::Cell { rect: Rect::new(x, 0.0, 1.0, 1.0), covered: false, table: DocPath(table), row, cell, story, clip }
|
||||
}
|
||||
|
||||
/// Which cell (by its `x`) clips a line at `path` of `story`.
|
||||
fn found(clips: &Clips, story: StoryRef, path: &[u32]) -> Option<f32> {
|
||||
cell_clip(clips, story, path).map(|r| r.x)
|
||||
}
|
||||
|
||||
/// A line in a cell cut off at its edge is drawn with that cell's clip, found by path in an
|
||||
/// index built once per page: every line used to look through every clipped cell on the page,
|
||||
/// so a page of exact-height rows cost lines x cells.
|
||||
#[test]
|
||||
fn a_line_finds_its_clipped_cell_by_path() {
|
||||
let mut page = Page {
|
||||
items: vec![cell(StoryRef::Body, vec![0], 0, 1, false, 1.0), cell(StoryRef::Body, vec![2], 1, 0, true, 2.0)],
|
||||
..Default::default()
|
||||
};
|
||||
// A nested clipped cell inside the second one; the outer one comes first in the page.
|
||||
page.items.push(cell(StoryRef::Body, vec![2, 1, 0, 3], 0, 0, true, 3.0));
|
||||
page.footer.push(cell(StoryRef::Part(7), vec![0], 0, 0, true, 4.0));
|
||||
// The page's cells in header, footer, items order, as the PDF export and layout see them.
|
||||
let all: Vec<Placed> = page.header.iter().chain(&page.footer).chain(&page.items).cloned().collect();
|
||||
let clips = clipped_cells(&all);
|
||||
assert_eq!(found(&clips, StoryRef::Body, &[2, 1, 0, 0]), Some(2.0));
|
||||
assert_eq!(found(&clips, StoryRef::Body, &[2, 1, 0, 3, 0, 0, 1]), Some(2.0), "the first cell in page order wins, as in layout");
|
||||
assert_eq!(found(&clips, StoryRef::Body, &[0, 0, 1, 0]), None, "that cell isn't clipped");
|
||||
assert_eq!(found(&clips, StoryRef::Body, &[2, 0, 0, 0]), None, "another row");
|
||||
assert_eq!(found(&clips, StoryRef::Part(7), &[0, 0, 0, 2]), Some(4.0));
|
||||
assert_eq!(found(&clips, StoryRef::Body, &[0, 0, 0, 2]), None, "another story");
|
||||
}
|
||||
|
||||
/// Thousands of clipped cells and as many lines: a lookup each, not a pass over the cells each.
|
||||
#[test]
|
||||
fn looking_up_many_clipped_cells_takes_linear_time() {
|
||||
let make = |n: usize| (0..n).map(|r| cell(StoryRef::Body, vec![0], r, 0, true, r as f32)).collect::<Vec<_>>();
|
||||
crate::tests_linear::assert_linear_steps("clip lookups", 1000, make, |cells| {
|
||||
let clips = clipped_cells(cells);
|
||||
let hits = (0..cells.len() as u32).filter(|r| cell_clip(&clips, StoryRef::Body, &[0, *r, 0, 0]).is_some()).count();
|
||||
assert_eq!(hits, cells.len());
|
||||
});
|
||||
}
|
||||
}
|
||||
+104
-14
@@ -1,5 +1,7 @@
|
||||
//! Hit testing and caret geometry.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use wordcraft_doc::para::Wrap;
|
||||
use wordcraft_doc::props::TextDirection;
|
||||
use wordcraft_doc::{Document, Path, Pos, StoryRef};
|
||||
@@ -140,7 +142,72 @@ fn text_at(p: &Page, index: usize, x: f32, y: f32) -> Option<StoryRef> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The pieces of story `story`'s paragraphs on each page, by paragraph, for the pages looked at
|
||||
/// so far ([`DocLayout::pieces_in`]).
|
||||
struct PartPages<'a> {
|
||||
story: StoryRef,
|
||||
pages: HashMap<usize, HashMap<&'a Path, Vec<&'a Placed>>>,
|
||||
/// The first page that shows each paragraph, made the first time one is asked for, in one
|
||||
/// walk of the pages (not one walk of them for every paragraph).
|
||||
first: Option<HashMap<&'a Path, usize>>,
|
||||
}
|
||||
|
||||
impl<'a> PartPages<'a> {
|
||||
fn new(story: StoryRef) -> Self {
|
||||
PartPages { story, pages: HashMap::new(), first: None }
|
||||
}
|
||||
|
||||
/// The first page of `lay` that shows a piece of the paragraph at `path`.
|
||||
fn first_page(&mut self, lay: &'a DocLayout, path: &Path) -> Option<usize> {
|
||||
let story = self.story;
|
||||
let first = self.first.get_or_insert_with(|| {
|
||||
let mut first: HashMap<&'a Path, usize> = HashMap::new();
|
||||
for (pi, p) in lay.pages.iter().enumerate() {
|
||||
crate::work::steps(1);
|
||||
for it in items_of(p, story) {
|
||||
if let Placed::Lines { story: s, path, .. } = it
|
||||
&& *s == story
|
||||
{
|
||||
first.entry(path).or_insert(pi);
|
||||
}
|
||||
}
|
||||
}
|
||||
first
|
||||
});
|
||||
first.get(path).copied()
|
||||
}
|
||||
|
||||
/// The pieces of the paragraph at `path` on page `pi` of `lay`, as [`DocLayout::pieces_on`]
|
||||
/// gives them.
|
||||
fn on(&mut self, lay: &'a DocLayout, pi: usize, path: &Path) -> Vec<(usize, &'a Placed)> {
|
||||
let Some(p) = lay.pages.get(pi) else { return Vec::new() };
|
||||
crate::work::steps(1);
|
||||
let story = self.story;
|
||||
let index = self.pages.entry(pi).or_insert_with(|| {
|
||||
let mut index: HashMap<&'a Path, Vec<&'a Placed>> = HashMap::new();
|
||||
for it in items_of(p, story) {
|
||||
if let Placed::Lines { story: s, path, .. } = it
|
||||
&& *s == story
|
||||
{
|
||||
index.entry(path).or_default().push(it);
|
||||
}
|
||||
}
|
||||
index
|
||||
});
|
||||
index.get(path).map(|v| v.iter().map(|it| (pi, *it)).collect()).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The items of `page` that `story`'s pieces are among (each one looked at counted as a step of
|
||||
/// [`crate::work`]).
|
||||
fn items_of(page: &Page, story: StoryRef) -> Box<dyn Iterator<Item = &Placed> + '_> {
|
||||
let items = story_items(page, story);
|
||||
#[cfg(test)]
|
||||
let items: Box<dyn Iterator<Item = &Placed> + '_> = Box::new(items.inspect(|_| crate::work::steps(1)));
|
||||
items
|
||||
}
|
||||
|
||||
fn story_items(page: &Page, story: StoryRef) -> Box<dyn Iterator<Item = &Placed> + '_> {
|
||||
match story {
|
||||
StoryRef::Body => Box::new(page.items.iter()),
|
||||
// Headers and footers, and the text boxes in them, are drawn with the page's header and
|
||||
@@ -417,16 +484,24 @@ impl DocLayout {
|
||||
.map(|v| v.iter().filter_map(|(pi, ii)| self.pages.get(*pi).and_then(|p| p.items.get(*ii)).map(|it| (*pi, it))).collect())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
let mut first = || (0..self.pages.len()).find(|pi| !self.pieces_on(*pi, story, path).is_empty());
|
||||
self.part_pieces(page_hint, &mut first, &mut |pi| self.pieces_on(pi, story, path))
|
||||
}
|
||||
|
||||
/// Pieces of a paragraph of a story part (see [`Self::pieces`]), `on` giving those on a page.
|
||||
fn part_pieces<'a>(
|
||||
&'a self,
|
||||
page_hint: usize,
|
||||
first: &mut dyn FnMut() -> Option<usize>,
|
||||
on: &mut dyn FnMut(usize) -> Vec<(usize, &'a Placed)>,
|
||||
) -> Vec<(usize, &'a Placed)> {
|
||||
// Headers/footers repeat: prefer the hinted page, else the first that shows the part.
|
||||
let mut order: Vec<usize> = (0..self.pages.len()).collect();
|
||||
if page_hint < order.len() {
|
||||
order.retain(|p| *p != page_hint);
|
||||
order.insert(0, page_hint);
|
||||
}
|
||||
for pi in order {
|
||||
let mut found = self.pieces_on(pi, story, path);
|
||||
let hinted = if page_hint < self.pages.len() { on(page_hint) } else { Vec::new() };
|
||||
let start = if hinted.is_empty() { first() } else { Some(page_hint) };
|
||||
if let Some(pi) = start {
|
||||
let mut found = if hinted.is_empty() { on(pi) } else { hinted };
|
||||
if found.is_empty() {
|
||||
continue;
|
||||
return found;
|
||||
}
|
||||
// A note continued over pages has the rest of the paragraph on the pages before or
|
||||
// after (a repeated header shows all of it on every page: one page is enough).
|
||||
@@ -439,7 +514,7 @@ impl DocLayout {
|
||||
let mut k = pi;
|
||||
while lo > 0 && k > 0 {
|
||||
k -= 1;
|
||||
let more = self.pieces_on(k, story, path);
|
||||
let more = on(k);
|
||||
if more.is_empty() {
|
||||
break;
|
||||
}
|
||||
@@ -449,7 +524,7 @@ impl DocLayout {
|
||||
let mut k = pi;
|
||||
while hi < n && k + 1 < self.pages.len() {
|
||||
k += 1;
|
||||
let more = self.pieces_on(k, story, path);
|
||||
let more = on(k);
|
||||
if more.is_empty() {
|
||||
break;
|
||||
}
|
||||
@@ -461,6 +536,19 @@ impl DocLayout {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// [`Self::pieces`] for each of many paragraphs of `pages.story`: a part's pieces on a page
|
||||
/// are looked up in that page's index, made the first time it is looked at, so a selection
|
||||
/// over many header, footer or text box paragraphs walks each page's items once rather than
|
||||
/// once for every paragraph.
|
||||
fn pieces_in<'a>(&'a self, pages: &mut PartPages<'a>, path: &Path, page_hint: usize) -> Vec<(usize, &'a Placed)> {
|
||||
if pages.story == StoryRef::Body {
|
||||
return self.pieces(StoryRef::Body, path, page_hint);
|
||||
}
|
||||
// The first-page index is made only when the hinted page doesn't show the paragraph.
|
||||
let pages = std::cell::RefCell::new(pages);
|
||||
self.part_pieces(page_hint, &mut || pages.borrow_mut().first_page(self, path), &mut |pi| pages.borrow_mut().on(self, pi, path))
|
||||
}
|
||||
|
||||
/// Pieces of a paragraph of story part `story` on page `pi`.
|
||||
fn pieces_on(&self, pi: usize, story: StoryRef, path: &Path) -> Vec<(usize, &Placed)> {
|
||||
let Some(p) = self.pages.get(pi) else { return Vec::new() };
|
||||
@@ -629,8 +717,9 @@ impl DocLayout {
|
||||
if a.story != b.story || !left.is_finite() || !right.is_finite() {
|
||||
return out;
|
||||
}
|
||||
let mut pages = PartPages::new(a.story);
|
||||
for path in doc.paths_between(a, b) {
|
||||
for (_, it) in self.pieces(a.story, &path, page_hint) {
|
||||
for (_, it) in self.pieces_in(&mut pages, &path, page_hint) {
|
||||
let Placed::Lines { para, l0, l1, x, .. } = it else { continue };
|
||||
let first = if path == a.path { para.line_of(a.off) } else { 0 };
|
||||
let last = if path == b.path { para.line_of(b.off) } else { usize::MAX };
|
||||
@@ -656,8 +745,9 @@ impl DocLayout {
|
||||
pub fn selection_rects(&self, doc: &Document, a: &Pos, b: &Pos, page_hint: usize) -> Vec<(usize, Rect)> {
|
||||
let (a, b) = if a <= b { (a, b) } else { (b, a) };
|
||||
let mut out = Vec::new();
|
||||
let mut pages = PartPages::new(a.story);
|
||||
for path in doc.paths_between(a, b) {
|
||||
for (pi, it) in self.pieces(a.story, &path, page_hint) {
|
||||
for (pi, it) in self.pieces_in(&mut pages, &path, page_hint) {
|
||||
let Placed::Lines { para, l0, l1, x, y, turn, .. } = it else { continue };
|
||||
let Some(first) = para.lines.get(*l0) else { continue };
|
||||
// Rectangles in the lines' own frame, turned onto the page.
|
||||
@@ -722,8 +812,8 @@ impl DocLayout {
|
||||
let l = para.lines.get(li)?;
|
||||
let here = para.x_of(li, pos.off)?;
|
||||
let mut best: Option<(usize, f32)> = None;
|
||||
for off in para.line_offsets(li) {
|
||||
let Some(x) = para.x_of(li, off) else { continue };
|
||||
for (off, x) in para.line_carets(li) {
|
||||
let Some(x) = x else { continue };
|
||||
let ahead = if left { x < here - 0.01 } else { x > here + 0.01 };
|
||||
let d = (x - here).abs();
|
||||
if ahead && best.is_none_or(|b| d < b.1) {
|
||||
|
||||
+153
-85
@@ -22,6 +22,7 @@ mod table;
|
||||
mod textbox;
|
||||
mod vertical;
|
||||
pub mod wordart;
|
||||
mod work;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::hash::{Hash, Hasher};
|
||||
@@ -533,8 +534,10 @@ pub struct LayoutCache {
|
||||
/// Narrowest and widest content of paragraphs measured for table autofit ([`Ctx::para_measured`]).
|
||||
measures: HashMap<Key, (f32, f32)>,
|
||||
/// Outlines of pictures' opaque pixels (Tight and Through wrapping), by media key, the
|
||||
/// media's bytes (address and length) and crop.
|
||||
outlines: HashMap<(String, usize, usize, [u32; 4]), Option<Arc<Vec<(f32, f32)>>>>,
|
||||
/// media's bytes (address and length) and crop. Kept from one layout to the next, so each
|
||||
/// entry holds a weak reference to its bytes: their allocation isn't freed while the entry is
|
||||
/// kept, so no other picture's bytes can come to have its address ([`Outlines`]).
|
||||
outlines: Outlines,
|
||||
env: u64,
|
||||
used: HashSet<Key>,
|
||||
/// The STYLEREF style index of the last pass and what it was built from: the body blocks
|
||||
@@ -694,11 +697,6 @@ enum Pass {
|
||||
/// Walk state while paginating.
|
||||
struct Ctx<'a> {
|
||||
doc: &'a Document,
|
||||
/// The body's sections ([`Document::sections`]) for one pass, with the block index each
|
||||
/// starts after: header and footer wrap work asks for them per page, and rebuilding the
|
||||
/// list walked the whole body every time, so a long document paid a body-length scan per
|
||||
/// page. The document does not change during a pass.
|
||||
sections: Vec<(usize, &'a SectionProps)>,
|
||||
cache: &'a mut LayoutCache,
|
||||
opts: &'a LayoutOptions,
|
||||
counters: Counters,
|
||||
@@ -743,6 +741,10 @@ struct Ctx<'a> {
|
||||
/// Each body float's latest placement: on which page and with what wrap area, when it is
|
||||
/// above its anchor paragraph with text before it ([`EarlyFloats`]).
|
||||
floats_above: HashMap<(usize, usize), Option<(usize, WrapArea)>>,
|
||||
/// The document's sections with their headers and footers linked ([`HfLinks`]).
|
||||
hf: Arc<HfLinks<'a>>,
|
||||
/// Pictures' transparency, decoded for Tight and Through wrapping ([`Ctx::wrap_outline`]).
|
||||
alphas: contour::Alphas,
|
||||
}
|
||||
|
||||
/// A header or footer (`story`) laid out for page `number` of a section (`sect`, `mirror`): its
|
||||
@@ -793,23 +795,42 @@ fn with_pin<T>(ctx: &mut Ctx, id: u32, sect: &SectionProps, number: u32, f: impl
|
||||
(out, behind, front)
|
||||
}
|
||||
|
||||
/// Picture outlines by media name, bytes (address and length) and crop ([`LayoutCache`]), each
|
||||
/// with a weak reference to those bytes. The cache outlives the documents it is used with, and
|
||||
/// an address names the same bytes only while their allocation lives: the weak reference keeps
|
||||
/// it (not the bytes in it), so a picture loaded later can't be given a freed picture's address
|
||||
/// and length and be taken for it.
|
||||
type Outlines = HashMap<(String, usize, usize, [u32; 4]), (std::sync::Weak<Vec<u8>>, Option<Arc<Vec<(f32, f32)>>>)>;
|
||||
|
||||
/// Picture `bytes`'s outline within `crop`, kept in `outlines` under `key`. One refused for this
|
||||
/// layout's decoding budget wraps as its rectangle and isn't kept: a later layout may decode it.
|
||||
fn picture_outline(
|
||||
outlines: &mut Outlines,
|
||||
alphas: &mut contour::Alphas,
|
||||
key: (String, usize, usize, [u32; 4]),
|
||||
bytes: &Arc<Vec<u8>>,
|
||||
crop: [f32; 4],
|
||||
) -> Option<Arc<Vec<(f32, f32)>>> {
|
||||
if let Some((_, o)) = outlines.get(&key) {
|
||||
return o.clone();
|
||||
}
|
||||
let outline = alphas.outline(bytes, crop).ok()?.map(Arc::new);
|
||||
if outlines.len() >= 256 {
|
||||
outlines.clear();
|
||||
}
|
||||
outlines.insert(key, (Arc::downgrade(bytes), outline.clone()));
|
||||
outline
|
||||
}
|
||||
|
||||
impl Ctx<'_> {
|
||||
/// The outline Tight and Through wrapping follow around `o` (see [`contour::wrap_outline`]);
|
||||
/// pictures' are worked out once per picture and crop.
|
||||
fn wrap_outline(&mut self, o: &InlineObject) -> Option<Arc<Vec<(f32, f32)>>> {
|
||||
let (doc, cache) = (self.doc, &mut *self.cache);
|
||||
let (doc, cache, alphas) = (self.doc, &mut *self.cache, &mut self.alphas);
|
||||
contour::wrap_outline(o, |media, crop| {
|
||||
let bytes = doc.media.get(media)?;
|
||||
let key = (media.to_string(), Arc::as_ptr(bytes) as usize, bytes.len(), crop.map(f32::to_bits));
|
||||
if let Some(o) = cache.outlines.get(&key) {
|
||||
return o.clone();
|
||||
}
|
||||
if cache.outlines.len() >= 256 {
|
||||
cache.outlines.clear();
|
||||
}
|
||||
let outline = contour::picture_outline(bytes, crop).map(Arc::new);
|
||||
cache.outlines.insert(key, outline.clone());
|
||||
outline
|
||||
picture_outline(&mut cache.outlines, alphas, key, bytes, crop)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2306,7 +2327,6 @@ fn layout_pass(
|
||||
});
|
||||
let mut ctx = Ctx {
|
||||
doc,
|
||||
sections: doc.sections(),
|
||||
cache,
|
||||
opts,
|
||||
hide_deleted: opts.hides_deleted(StoryRef::Body),
|
||||
@@ -2340,8 +2360,11 @@ fn layout_pass(
|
||||
footer_wraps: HashMap::new(),
|
||||
pin: None,
|
||||
floats_above: HashMap::new(),
|
||||
hf: Arc::new(HfLinks::new(doc.sections())),
|
||||
alphas: Default::default(),
|
||||
};
|
||||
let sections = doc.sections();
|
||||
let hf = Arc::clone(&ctx.hf);
|
||||
let sections = &hf.sections;
|
||||
let web = matches!(opts.view, ViewMode::Web | ViewMode::Draft | ViewMode::Outline);
|
||||
let read = opts.view == ViewMode::Read;
|
||||
let reflow = web || read;
|
||||
@@ -2471,9 +2494,9 @@ fn layout_pass(
|
||||
ctx.fields.section = u32::try_from(si).unwrap_or(u32::MAX).saturating_add(1);
|
||||
ctx.fields.section_pages = totals.section(si);
|
||||
// Headers and footers left out are linked to the previous section's, and take its room.
|
||||
let (heads, feet) = (linked(§ions, si, |s| s.headers), linked(§ions, si, |s| s.footers));
|
||||
let hb = |i: Option<u32>| defining_width(§ions, si, |s| s.headers, i);
|
||||
let fb = |i: Option<u32>| defining_width(§ions, si, |s| s.footers, i);
|
||||
let (heads, feet) = (hf.heads.set(si), hf.feet.set(si));
|
||||
let hb = |i: Option<u32>| hf.defining_width(false, si, i);
|
||||
let fb = |i: Option<u32>| hf.defining_width(true, si, i);
|
||||
let body_top = if reflow || turn.is_turned() {
|
||||
sect.margin_top
|
||||
} else {
|
||||
@@ -2513,7 +2536,7 @@ fn layout_pass(
|
||||
{
|
||||
pb.sect = prev;
|
||||
pb.sect_idx = si - 1;
|
||||
let (ph, pf) = (linked(§ions, si - 1, |s| s.headers), linked(§ions, si - 1, |s| s.footers));
|
||||
let (ph, pf) = (hf.heads.set(si - 1), hf.feet.set(si - 1));
|
||||
let top = body_top_for(&mut ctx, prev, ph.default);
|
||||
pb.foot_top = foot_top_for(&mut ctx, prev, pf.default);
|
||||
pb.new_page(block, top);
|
||||
@@ -2627,7 +2650,7 @@ fn layout_pass(
|
||||
}
|
||||
}
|
||||
if !reflow {
|
||||
let found = Totals::of(&pages, §ions);
|
||||
let found = Totals::of(&pages, sections);
|
||||
if ctx.uses_totals && may_repeat && found != *totals {
|
||||
return Pass::Again(found);
|
||||
}
|
||||
@@ -2635,13 +2658,13 @@ fn layout_pass(
|
||||
// Page references in headers and footers are filled in after pagination, like their page
|
||||
// totals: they don't make the body lay out again.
|
||||
if headers_footers_refer_to_pages(doc) {
|
||||
let marks = bookmark_pages(doc, &pages, §ions);
|
||||
let marks = bookmark_pages(doc, &pages, sections);
|
||||
ctx.fields.bookmarks_key = marks_key(&marks);
|
||||
ctx.fields.bookmark_pages = Arc::new(marks);
|
||||
}
|
||||
headers_footers(&mut ctx, &mut pages, §ions, &found);
|
||||
headers_footers(&mut ctx, &mut pages, sections, &found);
|
||||
if doc.settings.mirror_margins {
|
||||
mirror_even_pages(&mut pages, §ions);
|
||||
mirror_even_pages(&mut pages, sections);
|
||||
}
|
||||
}
|
||||
// Evict paragraph layouts that weren't used this pass if the cache grew large.
|
||||
@@ -2924,23 +2947,77 @@ fn section_side(start: SectionStart, restart: Option<u32>, last: u32, pages: usi
|
||||
}
|
||||
}
|
||||
|
||||
/// The text width of the section that defines header or footer `id`, when section `si` inherits
|
||||
/// it rather than naming it (`None` when it names it). Measured in Word: an inherited part's
|
||||
/// percentage-width tables are sized by that section, though it's laid out on `si`'s pages.
|
||||
fn defining_width(sections: &[(usize, &SectionProps)], si: usize, get: fn(&SectionProps) -> HeaderSet, id: Option<u32>) -> Option<f32> {
|
||||
let id = id?;
|
||||
let names = |s: &SectionProps| {
|
||||
let h = get(s);
|
||||
[h.default, h.first, h.even].contains(&Some(id))
|
||||
};
|
||||
let own = sections.get(si)?.1;
|
||||
if names(own) {
|
||||
return None;
|
||||
}
|
||||
sections.iter().take(si).rev().find(|(_, s)| names(s)).map(|(_, s)| s.text_width()).filter(|w| (w - own.text_width()).abs() > 0.01)
|
||||
/// The document's sections with their headers and footers (Word's "link to previous"), each
|
||||
/// section's resolved once for the whole layout in document order rather than by looking back
|
||||
/// over the sections before it whenever one is needed.
|
||||
struct HfLinks<'a> {
|
||||
sections: Vec<(usize, &'a SectionProps)>,
|
||||
heads: Linked,
|
||||
feet: Linked,
|
||||
}
|
||||
|
||||
/// Lay out with percentage-width tables sized by `base` (see [`defining_width`]).
|
||||
impl<'a> HfLinks<'a> {
|
||||
fn new(sections: Vec<(usize, &'a SectionProps)>) -> Self {
|
||||
let heads = Linked::new(§ions, |s| s.headers);
|
||||
let feet = Linked::new(§ions, |s| s.footers);
|
||||
HfLinks { sections, heads, feet }
|
||||
}
|
||||
|
||||
/// The text width of the section that defines header (or, `footer`, footer) `id`, when
|
||||
/// section `si` inherits it rather than naming it (`None` when it names it). Measured in
|
||||
/// Word: an inherited part's percentage-width tables are sized by that section, though it's
|
||||
/// laid out on `si`'s pages.
|
||||
fn defining_width(&self, footer: bool, si: usize, id: Option<u32>) -> Option<f32> {
|
||||
let id = id?;
|
||||
work::steps(1);
|
||||
let linked = if footer { &self.feet } else { &self.heads };
|
||||
let own = self.sections.get(si)?.1;
|
||||
if linked.own.get(si).is_some_and(|h| [h.default, h.first, h.even].contains(&Some(id))) {
|
||||
return None;
|
||||
}
|
||||
// The last section before `si` naming it.
|
||||
let namers = linked.namers.get(&id)?;
|
||||
let k = *namers.get(namers.partition_point(|&k| k < si).checked_sub(1)?)?;
|
||||
self.sections.get(k).map(|(_, s)| s.text_width()).filter(|w| (w - own.text_width()).abs() > 0.01)
|
||||
}
|
||||
}
|
||||
|
||||
/// The headers (or footers) of every section: those it names, and with those it leaves out
|
||||
/// inherited from the sections before it.
|
||||
struct Linked {
|
||||
own: Vec<HeaderSet>,
|
||||
linked: Vec<HeaderSet>,
|
||||
/// The sections naming each part, in order.
|
||||
namers: HashMap<u32, Vec<usize>>,
|
||||
}
|
||||
|
||||
impl Linked {
|
||||
fn new(sections: &[(usize, &SectionProps)], get: fn(&SectionProps) -> HeaderSet) -> Self {
|
||||
let own: Vec<HeaderSet> = sections.iter().map(|(_, s)| get(s)).collect();
|
||||
let mut linked = Vec::with_capacity(own.len());
|
||||
let mut namers: HashMap<u32, Vec<usize>> = HashMap::new();
|
||||
let mut prev = HeaderSet::default();
|
||||
for (si, o) in own.iter().enumerate() {
|
||||
work::steps(1);
|
||||
prev = HeaderSet { default: o.default.or(prev.default), first: o.first.or(prev.first), even: o.even.or(prev.even) };
|
||||
linked.push(prev);
|
||||
for id in [o.default, o.first, o.even].into_iter().flatten() {
|
||||
let v = namers.entry(id).or_default();
|
||||
if v.last() != Some(&si) {
|
||||
v.push(si);
|
||||
}
|
||||
}
|
||||
}
|
||||
Linked { own, linked, namers }
|
||||
}
|
||||
|
||||
/// Section `si`'s headers (or footers), those it leaves out inherited.
|
||||
fn set(&self, si: usize) -> HeaderSet {
|
||||
self.linked.get(si).copied().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Lay out with percentage-width tables sized by `base` (see [`HfLinks::defining_width`]).
|
||||
fn with_pct_base<T>(ctx: &mut Ctx, base: Option<f32>, f: impl FnOnce(&mut Ctx) -> T) -> T {
|
||||
let before = std::mem::replace(&mut ctx.pct_base, base);
|
||||
let out = f(ctx);
|
||||
@@ -3400,11 +3477,12 @@ fn footer_table_wraps(ctx: &mut Ctx, pb: &PageBuilder) -> Vec<WrapArea> {
|
||||
}
|
||||
ctx.footer_wraps.insert(key, Vec::new());
|
||||
let doc = ctx.doc;
|
||||
let Some((_, sect)) = ctx.sections.get(page.section).copied() else { return Vec::new() };
|
||||
let hf = Arc::clone(&ctx.hf);
|
||||
let Some((_, sect)) = hf.sections.get(page.section) else { return Vec::new() };
|
||||
if sect.text_direction.is_turned() {
|
||||
return Vec::new();
|
||||
}
|
||||
let set = linked(&ctx.sections, page.section, |s| s.footers);
|
||||
let set = hf.feet.set(page.section);
|
||||
let id = if sect.title_page && first {
|
||||
set.first
|
||||
} else if doc.settings.even_odd_headers && page.number.is_multiple_of(2) {
|
||||
@@ -3416,7 +3494,7 @@ fn footer_table_wraps(ctx: &mut Ctx, pb: &PageBuilder) -> Vec<WrapArea> {
|
||||
let Some(part) = doc.parts.get(&id) else { return Vec::new() };
|
||||
let blocks = part.blocks.clone();
|
||||
let x = sect.margin_left + sect.gutter;
|
||||
let base = defining_width(&ctx.sections, page.section, |s| s.footers, Some(id));
|
||||
let base = hf.defining_width(true, page.section, Some(id));
|
||||
let counters = std::mem::take(&mut ctx.counters);
|
||||
let boxes = std::mem::take(&mut ctx.boxes);
|
||||
let chains = std::mem::take(&mut ctx.chains);
|
||||
@@ -4350,6 +4428,14 @@ fn row_can_start(row: &wordcraft_doc::Row, laid: &table::RowLayout, room: f32, p
|
||||
table::split_row(laid, room).is_some()
|
||||
}
|
||||
|
||||
/// Header rows are re-placed on every page a table spills onto, so a table is O(header items ×
|
||||
/// pages) placed items — and an exact-height header row keeps all its overflowing text without
|
||||
/// growing, so a few KB of document could otherwise fill memory. Once this many items (a row
|
||||
/// counts at least one) have been repeated across a table, later pages carry no header rows;
|
||||
/// pagination still finishes. Far above any real table (three ordinary header rows over some
|
||||
/// 5 000 pages).
|
||||
const MAX_HEADER_ITEMS: usize = 500_000;
|
||||
|
||||
fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, block: usize, body_top: f32) {
|
||||
pb.prev = None;
|
||||
let width = pb.col_w();
|
||||
@@ -4370,13 +4456,10 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl
|
||||
// A table in the text flow starts below the floating objects in its way.
|
||||
below_floats(ctx, pb, pb.col_x() + tl.x, pb.col_x() + tl.x + tl.width);
|
||||
let header_rows: Vec<usize> = (0..t.rows.len()).take_while(|r| t.rows.get(*r).is_some_and(|row| row.props.header)).collect();
|
||||
// Header rows are re-placed on every page a table spills onto, so a table with many header
|
||||
// rows that each spill onto their own page is O(header rows × pages) placed items — a few KB
|
||||
// of document can otherwise fill memory. Once this many header rows have been repeated across
|
||||
// the whole table, later pages carry none; pagination still finishes. Far above any real
|
||||
// table (e.g. 3 header rows over 30 000 pages).
|
||||
const MAX_HEADER_REPEATS: usize = 200_000;
|
||||
// Items repeated so far, and what one more repeat of the header rows costs (see
|
||||
// `MAX_HEADER_ITEMS`).
|
||||
let mut header_repeats = 0usize;
|
||||
let header_cost = header_rows.iter().filter_map(|i| tl.rows.get(*i)).map(|r| r.items.len().max(1)).fold(0, usize::saturating_add);
|
||||
// Resolve kept chains once; nested paragraphs cannot keep their enclosing row.
|
||||
let mut kept: Vec<(usize, f32)> = (0..tl.rows.len()).map(|i| (i, 0.0)).collect();
|
||||
for i in (0..tl.rows.len().saturating_sub(1)).rev() {
|
||||
@@ -4476,15 +4559,10 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl
|
||||
}
|
||||
pb.advance(block, body_top);
|
||||
// Repeat header rows, up to this table's cap.
|
||||
if !header_rows.contains(&ri) && header_repeats < MAX_HEADER_REPEATS {
|
||||
for hr in &header_rows {
|
||||
if header_repeats >= MAX_HEADER_REPEATS {
|
||||
break;
|
||||
}
|
||||
if let Some(h) = tl.rows.get(*hr) {
|
||||
place(pb, h);
|
||||
header_repeats += 1;
|
||||
}
|
||||
if !header_rows.contains(&ri) && header_repeats.saturating_add(header_cost) <= MAX_HEADER_ITEMS {
|
||||
header_repeats += header_cost;
|
||||
for h in header_rows.iter().filter_map(|hr| tl.rows.get(*hr)) {
|
||||
place(pb, h);
|
||||
}
|
||||
}
|
||||
fresh = true;
|
||||
@@ -4534,11 +4612,12 @@ fn header_table_areas(ctx: &mut Ctx, pb: &PageBuilder) -> Vec<Rect> {
|
||||
if let Some(areas) = ctx.header_tables.get(&key) {
|
||||
return areas.clone();
|
||||
}
|
||||
let Some((_, sect)) = ctx.sections.get(page.section).copied() else { return Vec::new() };
|
||||
let hf = Arc::clone(&ctx.hf);
|
||||
let Some((_, sect)) = hf.sections.get(page.section) else { return Vec::new() };
|
||||
if sect.text_direction.is_turned() {
|
||||
return Vec::new();
|
||||
}
|
||||
let set = linked(&ctx.sections, page.section, |s| s.headers);
|
||||
let set = hf.heads.set(page.section);
|
||||
let id = if sect.title_page && first {
|
||||
set.first
|
||||
} else if ctx.doc.settings.even_odd_headers && page.number.is_multiple_of(2) {
|
||||
@@ -4551,7 +4630,7 @@ fn header_table_areas(ctx: &mut Ctx, pb: &PageBuilder) -> Vec<Rect> {
|
||||
let blocks = part.blocks.clone();
|
||||
let x = sect.margin_left + sect.gutter;
|
||||
let frame = PageFrame { sect, origin: (x, sect.header), number: page.number, mirror: ctx.mirror };
|
||||
let base = defining_width(&ctx.sections, page.section, |s| s.headers, Some(id));
|
||||
let base = hf.defining_width(false, page.section, Some(id));
|
||||
let counters = std::mem::take(&mut ctx.counters);
|
||||
let boxes = std::mem::take(&mut ctx.boxes);
|
||||
let chains = std::mem::take(&mut ctx.chains);
|
||||
@@ -4704,22 +4783,6 @@ fn below(excl: &[WrapArea], mut y: f32, x0: f32, x1: f32) -> f32 {
|
||||
y
|
||||
}
|
||||
|
||||
/// Section `si`'s headers (or footers, as `get` picks), with those it leaves out inherited from
|
||||
/// the sections before it (Word's "link to previous").
|
||||
fn linked(sections: &[(usize, &SectionProps)], si: usize, get: fn(&SectionProps) -> HeaderSet) -> HeaderSet {
|
||||
let mut set = sections.get(si).map(|(_, s)| get(s)).unwrap_or_default();
|
||||
for (_, s) in sections.iter().take(si).rev() {
|
||||
if set.default.is_some() && set.first.is_some() && set.even.is_some() {
|
||||
break;
|
||||
}
|
||||
let o = get(s);
|
||||
set.default = set.default.or(o.default);
|
||||
set.first = set.first.or(o.first);
|
||||
set.even = set.even.or(o.even);
|
||||
}
|
||||
set
|
||||
}
|
||||
|
||||
/// Whether a header, a footer or a text box shows a `PAGEREF` field, which they resolve from the
|
||||
/// paginated body (see [`bookmark_pages`]).
|
||||
fn headers_footers_refer_to_pages(doc: &Document) -> bool {
|
||||
@@ -4798,6 +4861,7 @@ fn marks_key(marks: &fields::BookmarkPages) -> u64 {
|
||||
|
||||
fn headers_footers(ctx: &mut Ctx, pages: &mut [Page], sections: &[(usize, &SectionProps)], totals: &Totals) {
|
||||
let total = pages.len() as u32;
|
||||
let hf = Arc::clone(&ctx.hf);
|
||||
let even_odd = ctx.doc.settings.even_odd_headers;
|
||||
let styles = styleref::headers_use_styleref(ctx.doc).then(|| ctx.cache.style_index(ctx.doc));
|
||||
let mut first_of_section: HashSet<usize> = HashSet::new();
|
||||
@@ -4810,8 +4874,8 @@ fn headers_footers(ctx: &mut Ctx, pages: &mut [Page], sections: &[(usize, &Secti
|
||||
for (i, page) in pages.iter_mut().enumerate() {
|
||||
let Some((_, sect)) = sections.get(page.section) else { continue };
|
||||
// Inherit header/footer references from earlier sections (Word's "link to previous").
|
||||
let pick = |get: fn(&SectionProps) -> HeaderSet| -> Option<u32> {
|
||||
let set = linked(sections, page.section, get);
|
||||
let pick = |linked: &Linked| -> Option<u32> {
|
||||
let set = linked.set(page.section);
|
||||
if sect.title_page && first_of_section.contains(&i) {
|
||||
return set.first;
|
||||
}
|
||||
@@ -4820,8 +4884,8 @@ fn headers_footers(ctx: &mut Ctx, pages: &mut [Page], sections: &[(usize, &Secti
|
||||
}
|
||||
set.default
|
||||
};
|
||||
let hid = pick(|s| s.headers);
|
||||
let fid = pick(|s| s.footers);
|
||||
let hid = pick(&hf.heads);
|
||||
let fid = pick(&hf.feet);
|
||||
page.hf_slot = if sect.title_page && first_of_section.contains(&i) {
|
||||
HfSlot::First
|
||||
} else if even_odd && page.number % 2 == 0 {
|
||||
@@ -4849,7 +4913,7 @@ fn headers_footers(ctx: &mut Ctx, pages: &mut [Page], sections: &[(usize, &Secti
|
||||
{
|
||||
let blocks = part.blocks.clone();
|
||||
let frame = PageFrame { sect, origin: (x, sect.header), number: page.number, mirror: ctx.mirror };
|
||||
let base = defining_width(sections, page.section, |s| s.headers, hid);
|
||||
let base = hf.defining_width(false, page.section, hid);
|
||||
let (mut items, behind, front) = with_pin(ctx, id, sect, page.number, |ctx| {
|
||||
with_pct_base(ctx, base, |c| layout_box(c, StoryRef::Part(id), &blocks, &[], w, BoxContext::Other, 0, Some(frame)).items)
|
||||
});
|
||||
@@ -4863,7 +4927,7 @@ fn headers_footers(ctx: &mut Ctx, pages: &mut [Page], sections: &[(usize, &Secti
|
||||
&& let Some(part) = ctx.doc.parts.get(&id)
|
||||
{
|
||||
let blocks = part.blocks.clone();
|
||||
let base = defining_width(sections, page.section, |s| s.footers, fid);
|
||||
let base = hf.defining_width(true, page.section, fid);
|
||||
// The footer's height fixes where it sits, which page-relative floats need.
|
||||
let (h, ..) = with_pin(ctx, id, sect, page.number, |ctx| {
|
||||
with_pct_base(ctx, base, |c| layout_box(c, StoryRef::Part(id), &blocks, &[], w, BoxContext::Other, 0, None).height)
|
||||
@@ -4927,12 +4991,16 @@ mod tests_autospace;
|
||||
#[cfg(test)]
|
||||
mod tests_bidi;
|
||||
#[cfg(test)]
|
||||
mod tests_budget;
|
||||
#[cfg(test)]
|
||||
mod tests_complex;
|
||||
#[cfg(test)]
|
||||
mod tests_grid;
|
||||
#[cfg(test)]
|
||||
mod tests_ligature;
|
||||
#[cfg(test)]
|
||||
mod tests_linear;
|
||||
#[cfg(test)]
|
||||
mod tests_nbsp;
|
||||
#[cfg(test)]
|
||||
mod tests_numeral;
|
||||
|
||||
@@ -631,19 +631,19 @@ impl Ctx {
|
||||
last_gid = Some(g.gid);
|
||||
}
|
||||
b.w = x;
|
||||
// Character boundaries: where the first glyph of each character starts.
|
||||
// Character boundaries: where the first glyph of each character starts. The glyph for a
|
||||
// boundary is never before the previous boundary's, so one walk of the glyphs finds all.
|
||||
let mut cuts = Vec::with_capacity(text.len() + 1);
|
||||
let (mut j, mut gx) = (0usize, 0.0f32);
|
||||
for (cb, _) in text.char_indices() {
|
||||
let mut gx = 0.0f32;
|
||||
let mut found = None;
|
||||
for g in &shaped {
|
||||
if g.cluster >= cb {
|
||||
found = Some(gx);
|
||||
break;
|
||||
}
|
||||
while let Some(g) = shaped.get(j)
|
||||
&& g.cluster < cb
|
||||
{
|
||||
crate::work::steps(1);
|
||||
gx += g.x_advance as f32 * k;
|
||||
j += 1;
|
||||
}
|
||||
cuts.push(found.unwrap_or(x));
|
||||
cuts.push(if j < shaped.len() { gx } else { x });
|
||||
}
|
||||
cuts.push(x);
|
||||
b.cuts = cuts;
|
||||
@@ -2374,4 +2374,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Finding 23: each character's boundary in a run of text was found by walking the run's
|
||||
/// glyphs from the first.
|
||||
#[test]
|
||||
fn a_long_text_run_lays_out_in_linear_time() {
|
||||
use crate::tests_linear::assert_linear_steps;
|
||||
use wordcraft_doc::math::*;
|
||||
let eq = |n: usize| Math { nodes: vec![MNode::Run(MRun { nor: true, ..MRun::new("a".repeat(n)) })], ..Default::default() };
|
||||
let lay = |m: &Math| drop(std::hint::black_box(lay_in(m, "", false, 1e6, &MathProps::default())));
|
||||
assert_linear_steps("math text run", 400, eq, lay);
|
||||
}
|
||||
}
|
||||
+48
-11
@@ -3107,20 +3107,43 @@ impl ParaLayout {
|
||||
let k = self.clusters.partition_point(|c| c.start < off);
|
||||
self.clusters.get(..k).into_iter().flatten().rev().find(|c| self.is_edit_boundary(c.start)).map_or(0, |c| c.start)
|
||||
};
|
||||
let mut from = l.c0;
|
||||
self.x_from(l, off, &mut from)
|
||||
}
|
||||
/// [`Self::x_of`] on line `l`, looking for the cluster at `off` from cluster `*from` on (the
|
||||
/// clusters before it must all end at or before `off`: so they do when `from` was left there
|
||||
/// by an offset no greater), and leaving `from` at the cluster found. Walks a line's carets
|
||||
/// in order in one pass over its clusters.
|
||||
fn x_from(&self, l: &Line, off: usize, from: &mut usize) -> Option<f32> {
|
||||
if !l.vis.is_empty() {
|
||||
return self.x_of_bidi(l, off);
|
||||
return self.x_of_bidi(l, off, from);
|
||||
}
|
||||
for k in l.c0..l.c1 {
|
||||
for k in *from..l.c1 {
|
||||
crate::work::steps(1);
|
||||
let c = self.clusters.get(k)?;
|
||||
if off <= c.start {
|
||||
return l.xs.get(k - l.c0).copied();
|
||||
}
|
||||
if off < c.end {
|
||||
if off <= c.start || off < c.end {
|
||||
*from = k;
|
||||
return l.xs.get(k - l.c0).copied();
|
||||
}
|
||||
}
|
||||
*from = l.c1;
|
||||
l.xs.last().copied()
|
||||
}
|
||||
/// Each caret offset on line `li` ([`Self::line_offsets`]) with its x ([`Self::x_of`]).
|
||||
pub fn line_carets(&self, li: usize) -> Vec<(usize, Option<f32>)> {
|
||||
let Some(l) = self.lines.get(li) else { return Vec::new() };
|
||||
let (mut from, mut last) = (l.c0, 0);
|
||||
self.line_offsets(li)
|
||||
.into_iter()
|
||||
.map(|off| {
|
||||
if off < last {
|
||||
from = l.c0;
|
||||
}
|
||||
last = off;
|
||||
(off, self.x_from(l, off, &mut from))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// The farthest right a caret goes on left-to-right line `li`: spaces at the end of a line
|
||||
/// hang past its right edge, but the caret after them stays at the edge, as in Word (#2146);
|
||||
/// text that overflows the line still carries it. `None` on bidirectional lines.
|
||||
@@ -3155,8 +3178,12 @@ impl ParaLayout {
|
||||
/// edge of right-to-left text), except where `off` leaves an embedded run (the cluster before
|
||||
/// it has the higher level), which keeps the caret at that run's trailing edge, so each
|
||||
/// visual boundary of the common mixed cases has its own offset.
|
||||
fn x_of_bidi(&self, l: &Line, off: usize) -> Option<f32> {
|
||||
let k = (l.c0..l.c1).find(|&k| self.clusters.get(k).is_some_and(|c| off < c.end || off <= c.start));
|
||||
fn x_of_bidi(&self, l: &Line, off: usize, from: &mut usize) -> Option<f32> {
|
||||
let k = (*from..l.c1).find(|&k| {
|
||||
crate::work::steps(1);
|
||||
self.clusters.get(k).is_some_and(|c| off < c.end || off <= c.start)
|
||||
});
|
||||
*from = k.unwrap_or(l.c1);
|
||||
let Some(k) = k else { return Some(l.end_x()) };
|
||||
let c = self.clusters.get(k)?;
|
||||
let here = l.vis.get(k - l.c0)?;
|
||||
@@ -3204,8 +3231,8 @@ impl ParaLayout {
|
||||
let Some(l) = self.lines.get(li) else { return self.text_len };
|
||||
if !l.vis.is_empty() {
|
||||
let mut best = (l.start, f32::MAX);
|
||||
for off in self.line_offsets(li) {
|
||||
let d = self.x_of(li, off).map_or(f32::MAX, |cx| (cx - x).abs());
|
||||
for (off, cx) in self.line_carets(li) {
|
||||
let d = cx.map_or(f32::MAX, |cx| (cx - x).abs());
|
||||
if d < best.1 {
|
||||
best = (off, d);
|
||||
}
|
||||
@@ -3319,8 +3346,18 @@ fn proof_issues(
|
||||
/// U+0001 bytes (neither words nor spaces).
|
||||
pub fn proof_text(p: &wordcraft_doc::Paragraph) -> String {
|
||||
let mut out = String::with_capacity(p.text.len());
|
||||
// The run holding each character ([`Paragraph::props_of_char`]), found by walking the runs
|
||||
// along with the text rather than from the first run for every character.
|
||||
let mut runs = p.runs.iter();
|
||||
let mut run = runs.next();
|
||||
let mut end = run.map_or(0, |r| r.len);
|
||||
for (i, c) in p.text.char_indices() {
|
||||
let deleted = c == wordcraft_doc::para::OBJ || p.props_of_char(i).del.is_some();
|
||||
while run.is_some() && i >= end {
|
||||
run = runs.next();
|
||||
end = end.saturating_add(run.map_or(0, |r| r.len));
|
||||
}
|
||||
let props = run.map_or(&p.mark, |r| &r.props);
|
||||
let deleted = c == wordcraft_doc::para::OBJ || props.del.is_some();
|
||||
if deleted {
|
||||
for _ in 0..c.len_utf8() {
|
||||
out.push('\u{1}');
|
||||
|
||||
@@ -71,6 +71,13 @@ struct Turned {
|
||||
top: f32,
|
||||
}
|
||||
|
||||
/// The number of grid columns of `t` ([`Table::cols`]), which walks every row (each row counted
|
||||
/// as a step of [`crate::work`]).
|
||||
fn grid_cols(t: &Table) -> usize {
|
||||
crate::work::steps(t.rows.len());
|
||||
t.cols()
|
||||
}
|
||||
|
||||
/// How long the lines laid out in `items` are without the room they didn't use: the length
|
||||
/// turned text needs so that nothing wraps.
|
||||
fn natural_length(items: &[Placed]) -> f32 {
|
||||
@@ -178,7 +185,7 @@ pub fn measure_table_columns(doc: &wordcraft_doc::Document, t: &Table) -> Vec<(f
|
||||
use wordcraft_doc::Block;
|
||||
let style = t.props.style.as_deref().and_then(|id| doc.styles.table_style(id));
|
||||
let margins_def = default_margins(t, style.as_ref());
|
||||
let ncols = t.cols().clamp(1, wordcraft_doc::table::MAX_COLS);
|
||||
let ncols = grid_cols(t).clamp(1, wordcraft_doc::table::MAX_COLS);
|
||||
let fields = crate::FieldCtx::default();
|
||||
let mut cols = vec![(0.0f32, 0.0f32); ncols];
|
||||
// Cells spanning several columns: (first column, span, min, max), applied after the others.
|
||||
@@ -360,7 +367,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
||||
// Cell text formatting and looks per set of style parts, built once per table.
|
||||
let mut cell_text: Vec<(Region, CellText)> = Vec::new();
|
||||
let mut looks: Vec<(Region, RegionLook)> = Vec::new();
|
||||
let ncols = t.cols().max(1);
|
||||
let ncols = grid_cols(t).max(1);
|
||||
// Column widths.
|
||||
let mut grid: Vec<f32> = if t.grid.len() == ncols {
|
||||
// Grid units can be narrower than a text box, including omitted row-end columns.
|
||||
@@ -530,7 +537,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
||||
let row_borders = row.props.exceptions.as_ref().and_then(|e| e.borders).unwrap_or_default();
|
||||
// A row that skips grid columns (`w:gridBefore`/`w:gridAfter`) has its edge at its own
|
||||
// first and last cell.
|
||||
let (first_g, last_g) = (g == row.first_grid(), g + span >= ncols.saturating_sub(row.props.grid_after as usize));
|
||||
let (first_g, last_g) = (g == row.first_grid(), g + span >= ncols.saturating_sub(row.grid_after()));
|
||||
let rb = look.map(|l| region_borders(&l.borders, (ri, nrows), (first_g, last_g))).unwrap_or_default();
|
||||
let mut borders = Borders {
|
||||
top: edge(cb.top.or(if ri == 0 { row_borders.top } else { row_borders.between }).or(rb.top), ri == 0, tb.top, tb.between),
|
||||
@@ -1122,7 +1129,9 @@ fn region_text(st: &TableStyleProps, region: Region) -> CellText {
|
||||
/// with their own. `None` when every cell is empty.
|
||||
fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, depth: usize) -> Option<Vec<(f32, f32)>> {
|
||||
let margins_def = default_margins(t, style);
|
||||
let mut cols = vec![(0.0f32, 0.0f32); t.cols().max(1)];
|
||||
// The grid's width once: counting it walks every row.
|
||||
let ncols = grid_cols(t);
|
||||
let mut cols = vec![(0.0f32, 0.0f32); ncols.max(1)];
|
||||
let mut spanning = Vec::new();
|
||||
let mut any = false;
|
||||
for (ri, row) in t.rows.iter().enumerate() {
|
||||
@@ -1145,7 +1154,7 @@ fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, dep
|
||||
spanning.extend(row_gaps(row, g).map(|(g, span, w)| (g, span, (w, w))));
|
||||
}
|
||||
for (g, span, need) in spanning {
|
||||
let Some(cs) = cols.get_mut(g..(g + span).min(t.cols())) else { continue };
|
||||
let Some(cs) = cols.get_mut(g..g.saturating_add(span).min(ncols)) else { continue };
|
||||
let n = cs.len().max(1) as f32;
|
||||
let (have_lo, have_hi) = cs.iter().fold((0.0, 0.0), |(l, h), c| (l + c.0, h + c.1));
|
||||
for c in cs {
|
||||
@@ -1161,7 +1170,7 @@ fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, dep
|
||||
/// columns, width in points). Column widths make room for them like for an empty cell.
|
||||
fn row_gaps(row: &Row, end: usize) -> impl Iterator<Item = (usize, usize, f32)> {
|
||||
let p = &row.props;
|
||||
[(0, row.first_grid(), p.w_before), (end, p.grid_after as usize, p.w_after)]
|
||||
[(0, row.first_grid(), p.w_before), (end, row.grid_after(), p.w_after)]
|
||||
.into_iter()
|
||||
.filter(|&(_, n, _)| n > 0)
|
||||
.filter_map(|(g, n, w)| w.filter(|w| w.is_finite() && *w > 0.0).map(|w| (g, n, w.min(1584.0))))
|
||||
|
||||
@@ -738,7 +738,7 @@ fn hidden_text_adds_no_line_break_opportunity_when_not_shown() {
|
||||
|
||||
/// How much bigger the second document is in the linear-time tests. At 8x a linear layout costs
|
||||
/// about 8x as much and a quadratic one about 64x, so a limit of 24x stays far from both.
|
||||
const SCALE: usize = 8;
|
||||
pub(crate) const SCALE: usize = 8;
|
||||
|
||||
/// The CPU time `f` takes on this thread, in seconds. On Linux it is the scheduler's count of the
|
||||
/// time the thread actually ran, so the time it waits while other work on a busy machine has the
|
||||
@@ -761,7 +761,7 @@ fn cpu_cost(f: &mut impl FnMut()) -> f64 {
|
||||
/// cheapest of several runs after a warm-up, the runs alternating so a busy spell slows both sizes
|
||||
/// alike; `small` is timed `scale` times over per run, so both timings are long against the clock's
|
||||
/// resolution.
|
||||
fn cost_ratio(scale: usize, mut small: impl FnMut(), mut big: impl FnMut()) -> (f64, f64, f64) {
|
||||
pub(crate) fn cost_ratio(scale: usize, mut small: impl FnMut(), mut big: impl FnMut()) -> (f64, f64, f64) {
|
||||
small();
|
||||
big();
|
||||
let mut small_scaled = || (0..scale).for_each(|_| small());
|
||||
@@ -774,7 +774,7 @@ fn cost_ratio(scale: usize, mut small: impl FnMut(), mut big: impl FnMut()) -> (
|
||||
}
|
||||
|
||||
/// [`cost_ratio`] of laying out `doc(SCALE * n)` against `doc(n)`.
|
||||
fn layout_time_ratio(doc: impl Fn(usize) -> Document, n: usize, opts: &LayoutOptions) -> (f64, f64, f64) {
|
||||
pub(crate) fn layout_time_ratio(doc: impl Fn(usize) -> Document, n: usize, opts: &LayoutOptions) -> (f64, f64, f64) {
|
||||
let (small, big) = (doc(n), doc(SCALE * n));
|
||||
cost_ratio(
|
||||
SCALE,
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
//! Hostile documents whose layout work or memory would otherwise grow far past their size: each
|
||||
//! is bounded by a cap, and real documents stay well under it.
|
||||
|
||||
use super::*;
|
||||
use wordcraft_doc::props::HeightRule;
|
||||
use wordcraft_doc::table::{MAX_COLS, Table};
|
||||
use wordcraft_doc::{Block, PartKind, Pos, TextBody, para_block};
|
||||
|
||||
fn lay(doc: &Document) -> DocLayout {
|
||||
let mut c = LayoutCache::new();
|
||||
layout(doc, &mut c, &LayoutOptions::default())
|
||||
}
|
||||
|
||||
/// A `w` × `h` text box showing `text`, inline at the end of body paragraph `para`; its story id.
|
||||
fn text_box(d: &mut Document, para: usize, text: &str, w: f32, h: f32) -> u32 {
|
||||
let id = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text(text, Default::default()))]);
|
||||
if let Some(p) = d.parts.get_mut(&id) {
|
||||
p.body = TextBody::default();
|
||||
}
|
||||
let shape = InlineObject::Shape {
|
||||
kind: wordcraft_doc::para::ShapeKind::TextBox,
|
||||
w,
|
||||
h,
|
||||
fill: None,
|
||||
stroke: None,
|
||||
stroke_width: 1.0,
|
||||
float: Default::default(),
|
||||
story: Some(id),
|
||||
effects: Default::default(),
|
||||
freeform: None,
|
||||
extra: Default::default(),
|
||||
};
|
||||
let off = d.para(StoryRef::Body, &Path::top(para)).map_or(0, |p| p.len());
|
||||
d.insert_object(&Pos::body(para, off), shape, &Default::default()).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_linked_chain_lays_its_story_out_at_a_bounded_number_of_widths() {
|
||||
// Forty linked boxes, each a line deep and each a different width, over a long story: every
|
||||
// width would lay the whole story out again. Past the cap, later boxes reuse the nearest
|
||||
// width already laid out, so every box still shows its share of the story.
|
||||
let n = 40;
|
||||
let mut d = Document::from_text(&vec!["p"; n].join("\n"));
|
||||
let story = "Words that run on and on through every box of the chain. ".repeat(200);
|
||||
let ids: Vec<u32> = (0..n).map(|i| text_box(&mut d, i, if i == 0 { &story } else { "" }, 100.0 + 3.0 * i as f32, 28.0)).collect();
|
||||
for w in ids.windows(2) {
|
||||
d.parts.get_mut(&w[0]).unwrap().body.next = Some(w[1]);
|
||||
}
|
||||
let l = lay(&d);
|
||||
let lines: Vec<&Placed> =
|
||||
l.pages.iter().flat_map(|p| &p.items).filter(|it| matches!(it, Placed::Lines { story: StoryRef::Part(s), .. } if *s == ids[0])).collect();
|
||||
assert_eq!(lines.len(), n, "every box shows the story");
|
||||
// Each width the story was laid out at is its own paragraph layout.
|
||||
let mut widths: Vec<usize> =
|
||||
lines.iter().filter_map(|it| if let Placed::Lines { para, .. } = it { Some(Arc::as_ptr(para) as usize) } else { None }).collect();
|
||||
widths.sort_unstable();
|
||||
widths.dedup();
|
||||
assert!(widths.len() <= textbox::MAX_FLOW_WIDTHS, "{} widths laid the story out", widths.len());
|
||||
}
|
||||
|
||||
/// The paragraph layouts (by address) showing story `head` across the document.
|
||||
fn story_layouts(l: &DocLayout, head: u32) -> Vec<usize> {
|
||||
l.pages
|
||||
.iter()
|
||||
.flat_map(|p| &p.items)
|
||||
.filter_map(|it| match it {
|
||||
Placed::Lines { story: StoryRef::Part(s), para, .. } if *s == head => Some(Arc::as_ptr(para) as usize),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacked_boxes_of_any_width_share_a_layout() {
|
||||
// Stacked text is laid out one letter to a line whatever the box's width.
|
||||
let n = 20;
|
||||
let mut d = Document::from_text(&vec!["p"; n].join("\n"));
|
||||
let story = "Stacked letters run on through every box. ".repeat(20);
|
||||
let ids: Vec<u32> = (0..n).map(|i| text_box(&mut d, i, if i == 0 { &story } else { "" }, 100.0 + 3.0 * i as f32, 28.0)).collect();
|
||||
for id in &ids {
|
||||
d.parts.get_mut(id).unwrap().body.vert = wordcraft_doc::props::TextVert::Stacked;
|
||||
}
|
||||
for w in ids.windows(2) {
|
||||
d.parts.get_mut(&w[0]).unwrap().body.next = Some(w[1]);
|
||||
}
|
||||
let mut widths = story_layouts(&lay(&d), ids[0]);
|
||||
assert_eq!(widths.len(), n, "every box shows the story");
|
||||
widths.sort_unstable();
|
||||
widths.dedup();
|
||||
assert_eq!(widths.len(), 1, "one layout for every stacked box");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn past_the_cap_a_box_takes_a_layout_in_its_own_direction() {
|
||||
// The cap is full of horizontal widths when a vertical box comes: it lays the story out
|
||||
// its own way rather than showing a horizontal layout.
|
||||
let n = textbox::MAX_FLOW_WIDTHS + 1;
|
||||
let mut d = Document::from_text(&vec!["p"; n].join("\n"));
|
||||
let story = "Words that run on and on through every box of the chain. ".repeat(200);
|
||||
let ids: Vec<u32> = (0..n).map(|i| text_box(&mut d, i, if i == 0 { &story } else { "" }, 100.0 + 3.0 * i as f32, 28.0)).collect();
|
||||
if let Some(last) = ids.last() {
|
||||
d.parts.get_mut(last).unwrap().body.vert = wordcraft_doc::props::TextVert::Vert;
|
||||
}
|
||||
for w in ids.windows(2) {
|
||||
d.parts.get_mut(&w[0]).unwrap().body.next = Some(w[1]);
|
||||
}
|
||||
let mut widths = story_layouts(&lay(&d), ids[0]);
|
||||
assert_eq!(widths.len(), n, "every box shows the story");
|
||||
widths.sort_unstable();
|
||||
widths.dedup();
|
||||
assert_eq!(widths.len(), n, "the vertical box has a layout of its own");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boxes_a_hair_apart_in_width_share_a_layout() {
|
||||
// Resized boxes differ by fractions of a point; those within a twentieth of a point of each
|
||||
// other share one layout of the story.
|
||||
let n = 30;
|
||||
let mut d = Document::from_text(&vec!["p"; n].join("\n"));
|
||||
let story = "Words that run on and on through every box of the chain. ".repeat(100);
|
||||
let ids: Vec<u32> = (0..n).map(|i| text_box(&mut d, i, if i == 0 { &story } else { "" }, 150.0 + 0.0005 * i as f32, 28.0)).collect();
|
||||
for w in ids.windows(2) {
|
||||
d.parts.get_mut(&w[0]).unwrap().body.next = Some(w[1]);
|
||||
}
|
||||
let l = lay(&d);
|
||||
let mut widths: Vec<usize> = l
|
||||
.pages
|
||||
.iter()
|
||||
.flat_map(|p| &p.items)
|
||||
.filter_map(|it| match it {
|
||||
Placed::Lines { story: StoryRef::Part(s), para, .. } if *s == ids[0] => Some(Arc::as_ptr(para) as usize),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(widths.len(), n, "every box shows the story");
|
||||
widths.sort_unstable();
|
||||
widths.dedup();
|
||||
assert_eq!(widths.len(), 1, "one layout for every box");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_header_items_are_capped_across_the_table() {
|
||||
// An exact-height header row keeps thousands of overflowing paragraphs without growing, and
|
||||
// repeats on each of hundreds of pages: every page would copy all of them.
|
||||
let mut t = Table::new(201, 1, 468.0);
|
||||
t.rows[0].props.header = true;
|
||||
t.rows[0].props.height = Some(20.0);
|
||||
t.rows[0].props.height_rule = HeightRule::Exact;
|
||||
t.rows[0].cells[0].blocks = (0..3000).map(|_| para_block(Paragraph::with_text("x", Default::default()))).collect();
|
||||
for row in t.rows.iter_mut().skip(1) {
|
||||
row.props.height = Some(600.0);
|
||||
row.props.height_rule = HeightRule::Exact;
|
||||
}
|
||||
let mut d = Document::from_text("");
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(t)).unwrap();
|
||||
let l = lay(&d);
|
||||
assert!(l.pages.len() >= 200, "{} pages", l.pages.len());
|
||||
let items: usize = l.pages.iter().map(|p| p.items.len()).sum();
|
||||
// The first page's header and each page's body row come on top of the repeats.
|
||||
assert!(items <= MAX_HEADER_ITEMS + 3000 + 50 * l.pages.len(), "{items} items placed");
|
||||
// An ordinary table's header still repeats on every page.
|
||||
let mut t = Table::new(80, 2, 468.0);
|
||||
t.rows[0].props.header = true;
|
||||
let mut d = Document::from_text("");
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(t)).unwrap();
|
||||
let l = lay(&d);
|
||||
assert!(l.pages.len() >= 2);
|
||||
for p in &l.pages {
|
||||
assert!(p.items.iter().any(|i| matches!(i, Placed::Cell { row: 0, .. })), "the header repeats");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_grid_after_is_bounded_like_the_docx_readers() {
|
||||
// A native document can say a row leaves four billion columns empty after its cells.
|
||||
let mut t = Table::new(1, 2, 468.0);
|
||||
t.rows[0].props.grid_after = u32::MAX;
|
||||
t.grid.clear();
|
||||
assert!(t.cols() <= 2 + MAX_COLS, "{} columns", t.cols());
|
||||
let mut d = Document::from_text("");
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(t)).unwrap();
|
||||
let l = lay(&d);
|
||||
assert!(l.pages[0].items.iter().any(|i| matches!(i, Placed::Cell { row: 0, cell: 1, .. })));
|
||||
}
|
||||
|
||||
/// A `size` × `size` PNG, transparent but for an opaque disc in the middle.
|
||||
fn disc_png(size: u32) -> Vec<u8> {
|
||||
let c = size as f32 / 2.0;
|
||||
let img = image::RgbaImage::from_fn(size, size, |x, y| {
|
||||
let (dx, dy) = (x as f32 + 0.5 - c, y as f32 + 0.5 - c);
|
||||
image::Rgba([0, 0, 0, if dx * dx + dy * dy < c * c * 0.5 { 255 } else { 0 }])
|
||||
});
|
||||
let mut buf = Vec::new();
|
||||
image::DynamicImage::from(img).write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).unwrap();
|
||||
buf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_cropped_many_ways_is_decoded_once() {
|
||||
let png = disc_png(64);
|
||||
let mut alphas = contour::Alphas::default();
|
||||
for i in 0..300 {
|
||||
let f = i as f32 / 1000.0;
|
||||
assert!(alphas.outline(&png, [f, f, 0.0, 0.0]).is_ok_and(|o| o.is_some()), "crop {i} follows the disc");
|
||||
}
|
||||
assert_eq!(alphas.decoded, 64 * 64, "one decode for every crop");
|
||||
// A crop's outline is the one the picture alone gives.
|
||||
let mut fresh = contour::Alphas::default();
|
||||
assert_eq!(alphas.outline(&png, [0.1, 0.0, 0.2, 0.0]), fresh.outline(&png, [0.1, 0.0, 0.2, 0.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pictures_cropped_in_turn_are_each_decoded_once() {
|
||||
// Crops alternating between two pictures don't decode either again: enough of them would
|
||||
// otherwise spend the budget on pictures that fit in it.
|
||||
let (a, b) = (disc_png(64), disc_png(32));
|
||||
let mut alphas = contour::Alphas::default();
|
||||
for i in 0..300 {
|
||||
let f = i as f32 / 1000.0;
|
||||
assert!(alphas.outline(&a, [f, 0.0, 0.0, 0.0]).is_ok_and(|o| o.is_some()), "crop {i} of a");
|
||||
assert!(alphas.outline(&b, [0.0, f, 0.0, 0.0]).is_ok_and(|o| o.is_some()), "crop {i} of b");
|
||||
}
|
||||
assert_eq!(alphas.decoded, 64 * 64 + 32 * 32, "one decode per picture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoding_pictures_stops_at_the_layouts_budget() {
|
||||
let (a, b) = (disc_png(64), disc_png(32));
|
||||
let mut alphas = contour::Alphas::default();
|
||||
alphas.decoded = contour::MAX_DECODED_PIXELS - 64 * 64 + 1;
|
||||
// Too many pixels left to decode: the picture wraps as its rectangle, and says why.
|
||||
assert_eq!(alphas.outline(&a, [0.0; 4]), Err(contour::OverBudget));
|
||||
// A smaller one still fits.
|
||||
assert!(alphas.outline(&b, [0.0; 4]).is_ok_and(|o| o.is_some()));
|
||||
assert!(alphas.decoded <= contour::MAX_DECODED_PIXELS);
|
||||
// Pictures alternating past the budget aren't decoded again.
|
||||
for _ in 0..10 {
|
||||
assert_eq!(alphas.outline(&a, [0.0; 4]), Err(contour::OverBudget));
|
||||
let _ = alphas.outline(&b, [0.0; 4]);
|
||||
}
|
||||
assert!(alphas.decoded <= contour::MAX_DECODED_PIXELS);
|
||||
}
|
||||
|
||||
/// A `size` × `size` PNG with no transparency.
|
||||
fn opaque_png(size: u32) -> Vec<u8> {
|
||||
let img = image::RgbImage::from_pixel(size, size, image::Rgb([0, 0, 0]));
|
||||
let mut buf = Vec::new();
|
||||
image::DynamicImage::from(img).write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).unwrap();
|
||||
buf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_pictures_take_none_of_the_decoding_budget() {
|
||||
// Pictures whose header says they have no transparency have no outline to find: decoding
|
||||
// them would only spend the budget a transparent picture after them needs.
|
||||
let (opaque, disc) = (opaque_png(64), disc_png(64));
|
||||
let mut alphas = contour::Alphas::default();
|
||||
alphas.decoded = contour::MAX_DECODED_PIXELS - 64 * 64;
|
||||
for _ in 0..3 {
|
||||
assert_eq!(alphas.outline(&opaque, [0.0; 4]), Ok(None));
|
||||
}
|
||||
assert!(alphas.outline(&disc, [0.0; 4]).is_ok_and(|o| o.is_some()), "the transparent picture still has its outline");
|
||||
assert_eq!(alphas.decoded, contour::MAX_DECODED_PIXELS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_refused_for_the_budget_is_not_cached_across_layouts() {
|
||||
// One layout ran out of budget before this picture; the next has a budget of its own.
|
||||
let disc = Arc::new(disc_png(64));
|
||||
let mut outlines = HashMap::new();
|
||||
let key = ("p".to_string(), 1, 2, [0; 4]);
|
||||
let mut spent = contour::Alphas::default();
|
||||
spent.decoded = contour::MAX_DECODED_PIXELS;
|
||||
assert!(picture_outline(&mut outlines, &mut spent, key.clone(), &disc, [0.0; 4]).is_none());
|
||||
assert!(outlines.is_empty(), "the refusal isn't kept");
|
||||
let mut fresh = contour::Alphas::default();
|
||||
assert!(picture_outline(&mut outlines, &mut fresh, key.clone(), &disc, [0.0; 4]).is_some());
|
||||
assert!(outlines.get(&key).is_some_and(|(_, o)| o.is_some()), "the outline is kept");
|
||||
}
|
||||
|
||||
/// The outline cache outlives the documents it is used with and tells pictures apart by their
|
||||
/// bytes' address: each entry keeps that allocation (a weak reference, not the bytes' owner), so
|
||||
/// it can't be freed and the address given to another picture's bytes while the entry is kept.
|
||||
#[test]
|
||||
fn a_kept_outline_holds_on_to_its_pictures_allocation() {
|
||||
let disc = Arc::new(disc_png(64));
|
||||
let mut outlines = HashMap::new();
|
||||
let key = ("p".to_string(), Arc::as_ptr(&disc) as usize, disc.len(), [0; 4]);
|
||||
assert!(picture_outline(&mut outlines, &mut contour::Alphas::default(), key.clone(), &disc, [0.0; 4]).is_some());
|
||||
let Some((weak, _)) = outlines.get(&key) else { panic!("the outline is kept") };
|
||||
assert!(std::ptr::eq(weak.as_ptr(), Arc::as_ptr(&disc)), "the entry refers to its own picture's bytes");
|
||||
assert_eq!((Arc::strong_count(&disc), Arc::weak_count(&disc)), (1, 1), "a weak reference: the document still owns the bytes");
|
||||
drop(disc);
|
||||
assert!(weak.upgrade().is_none(), "the bytes are freed with the document; only their allocation is kept");
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
//! Lookups that once rescanned the page, the paragraph or the document for every item they
|
||||
//! looked up (#3692, #3706): an input 8x the size must cost about 8x as much, not 64x.
|
||||
//!
|
||||
//! The cost is the steps the lookups' walks count ([`crate::work`]), not a timing, so a busy
|
||||
//! machine can't make a linear lookup look quadratic or the other way round. Only proofing text,
|
||||
//! whose walk is the document model's, is timed.
|
||||
|
||||
use super::tests::{SCALE, cost_ratio};
|
||||
use super::{
|
||||
Block, CharProps, DocLayout, Document, HeaderSet, HeightRule, InlineObject, LayoutCache, LayoutOptions, Page, ParaLayout, Paragraph, Path,
|
||||
Placed, SectionProps, SectionStart, StoryRef, display, layout, para, work,
|
||||
};
|
||||
use wordcraft_doc::para::Run;
|
||||
use wordcraft_doc::props::Rgb;
|
||||
use wordcraft_doc::{Pos, Table};
|
||||
|
||||
fn lay(d: &Document, opts: &LayoutOptions) -> DocLayout {
|
||||
layout(d, &mut LayoutCache::new(), opts)
|
||||
}
|
||||
|
||||
/// The most steps 8x the input may take, against the steps of the input: linear walks take
|
||||
/// about 8x (a little more where a fixed cost is shared out), quadratic ones about 64x.
|
||||
const MAX_STEP_RATIO: f64 = 1.5 * SCALE as f64;
|
||||
|
||||
/// Asserts `f` takes about [`SCALE`] times the steps ([`crate::work`]) on `make(SCALE * n)` as on
|
||||
/// `make(n)`, not [`SCALE`]² times. Making the inputs isn't counted.
|
||||
pub(crate) fn assert_linear_steps<T>(what: &str, n: usize, make: impl Fn(usize) -> T, f: impl Fn(&T)) {
|
||||
let (small, big) = (make(n), make(SCALE * n));
|
||||
let (s, b) = (work::count(|| f(&small)), work::count(|| f(&big)));
|
||||
let ratio = b as f64 / s.max(1) as f64;
|
||||
eprintln!("{what}: {s} steps for n, {b} for {SCALE}n, ratio {ratio:.1}");
|
||||
assert!(s > 0, "{what}: no steps counted, so the walk this test watches no longer counts them (crate::work)");
|
||||
assert!(ratio <= MAX_STEP_RATIO, "{what}: {SCALE}x the input took {ratio:.1}x the steps ({s} → {b}): not linear");
|
||||
}
|
||||
|
||||
/// [`assert_linear_steps`] for laying out `doc(n)`.
|
||||
fn assert_layout_linear(what: &str, n: usize, doc: impl Fn(usize) -> Document, opts: &LayoutOptions) {
|
||||
assert_linear_steps(what, n, doc, |d| drop(std::hint::black_box(lay(d, opts))));
|
||||
}
|
||||
|
||||
/// The first laid-out body paragraph on page 0.
|
||||
fn first_para(l: &DocLayout) -> Option<&ParaLayout> {
|
||||
l.pages.first()?.items.iter().find_map(|it| match it {
|
||||
Placed::Lines { para, story: StoryRef::Body, .. } => Some(para.as_ref()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Finding 29: proofing text found each character's run from the paragraph's first run.
|
||||
#[test]
|
||||
fn proof_text_walks_the_runs_once() {
|
||||
// One run per character, every other one deleted.
|
||||
let para = |n: usize| {
|
||||
let mut p = Paragraph::with_text(&"a".repeat(n), CharProps::default());
|
||||
p.runs = (0..n).map(|i| Run { len: 1, props: CharProps { del: (i % 2 == 1).then_some(0), ..Default::default() } }).collect();
|
||||
p
|
||||
};
|
||||
assert_eq!(para::proof_text(¶(5)), "a\u{1}a\u{1}a");
|
||||
// The runs are the document model's, whose walk counts no steps, so this one is timed: the
|
||||
// CPU time this thread ran (`cost_ratio`). At 50,000 runs the old walk took 64x as long for
|
||||
// 8x the runs, and the new one about 8x; a limit of 24x stays far from both, and a ratio over
|
||||
// it is measured again, twice, before the test fails, so a busy spell can't fail it alone.
|
||||
let (small, big) = (para(50_000), para(SCALE * 50_000));
|
||||
let proof_8 = |p: &Paragraph| (0..8).for_each(|_| drop(std::hint::black_box(para::proof_text(p))));
|
||||
let mut ratios = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let (t1, tk, ratio) = cost_ratio(SCALE, || proof_8(&small), || proof_8(&big));
|
||||
eprintln!("proof_text: t(n) {t1:.4} s, t(8n) {tk:.4} s, ratio {ratio:.1}");
|
||||
if ratio < 24.0 {
|
||||
return;
|
||||
}
|
||||
ratios.push(ratio);
|
||||
}
|
||||
panic!("proof_text: 8x the runs took {ratios:.1?}x the time, three times over: not linear");
|
||||
}
|
||||
|
||||
/// A right-to-left paragraph whose one line holds `n` hidden letters: caret offsets with no width.
|
||||
fn bidi_hidden(n: usize) -> Document {
|
||||
let head = "سلام ";
|
||||
let mut d = Document::from_text(&format!("{head}{} world", "x".repeat(n)));
|
||||
let at = Pos::body(0, 0);
|
||||
d.format_paragraphs(&at, &at, &|p| p.bidi = Some(true)).unwrap();
|
||||
d.format_range(&Pos::body(0, head.len()), &Pos::body(0, head.len() + n), &|c| c.hidden = Some(true)).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// Finding 5: a click on a bidirectional line and a visual caret step measured every caret
|
||||
/// offset from the line's first cluster.
|
||||
#[test]
|
||||
fn bidi_clicks_and_caret_steps_take_linear_time() {
|
||||
let opts = LayoutOptions { show_hidden: false, ..Default::default() };
|
||||
let laid = |n: usize| lay(&bidi_hidden(n), &opts);
|
||||
let Some(pb) = first_para(&laid(SCALE * 5000)).map(|p| p.lines.len()) else { panic!("no paragraph") };
|
||||
assert_eq!(pb, 1, "the hidden letters stay on one line");
|
||||
assert_linear_steps("bidi hit test", 5000, laid, |l| {
|
||||
let Some(p) = first_para(l) else { panic!("no paragraph") };
|
||||
std::hint::black_box(p.off_at_x(0, 1.0));
|
||||
std::hint::black_box(l.visual_step(&Pos::body(0, 0), true, 0));
|
||||
});
|
||||
}
|
||||
|
||||
/// The carets of a line walked in one pass are where `x_of` puts each of them, on bidirectional
|
||||
/// and left-to-right lines alike.
|
||||
#[test]
|
||||
fn line_carets_match_x_of() {
|
||||
let docs = [bidi_hidden(20), Document::from_text("Plain left-to-right text, long enough to wrap. ".repeat(6).as_str()), {
|
||||
let mut d = Document::from_text("سلام world 123 دنیا (مرحبا) end");
|
||||
let at = Pos::body(0, 0);
|
||||
d.format_paragraphs(&at, &at, &|p| p.bidi = Some(true)).unwrap();
|
||||
d
|
||||
}];
|
||||
for d in &docs {
|
||||
for show_hidden in [false, true] {
|
||||
let l = lay(d, &LayoutOptions { show_hidden, ..Default::default() });
|
||||
let Some(p) = first_para(&l) else { panic!("no paragraph") };
|
||||
for li in 0..p.lines.len() {
|
||||
let want: Vec<_> = p.line_offsets(li).into_iter().map(|off| (off, p.x_of(li, off))).collect();
|
||||
assert!(!want.is_empty());
|
||||
assert_eq!(p.line_carets(li), want, "line {li}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Finding 3: autofitting a table counted its grid's columns again for every spanning cell.
|
||||
#[test]
|
||||
fn autofit_tables_with_a_spanning_cell_in_every_row_lay_out_in_linear_time() {
|
||||
// Rows with no cells, only a column skipped before and after them with a width: two
|
||||
// spanning entries a row for next to no layout, so counting the grid per entry dominates.
|
||||
let doc = |n: usize| {
|
||||
let mut t = Table::new(n, 1, 468.0);
|
||||
for row in t.rows.iter_mut().skip(1) {
|
||||
row.cells.clear();
|
||||
row.props.grid_before = 1;
|
||||
row.props.w_before = Some(10.0);
|
||||
row.props.grid_after = 1;
|
||||
row.props.w_after = Some(10.0);
|
||||
}
|
||||
let mut d = Document::from_text("x");
|
||||
d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(t)).unwrap();
|
||||
d
|
||||
};
|
||||
assert_layout_linear("spanning cells", 2000, doc, &LayoutOptions::default());
|
||||
}
|
||||
|
||||
/// `n` continuous sections of an empty paragraph each, each with a different first page. The
|
||||
/// first section names every header and footer; the others name them too, or (`inherit`) name
|
||||
/// none and link to the previous section's.
|
||||
fn sections(n: usize, inherit: bool) -> Document {
|
||||
use wordcraft_doc::PartKind::{Footer, Header};
|
||||
let mut d = Document::from_text(&"\n".repeat(n.saturating_sub(1)));
|
||||
let mut part = |k| d.add_part(k, vec![wordcraft_doc::para_block(Paragraph::with_text("Head", CharProps::default()))]);
|
||||
let ids = [part(Header), part(Header), part(Header), part(Footer), part(Footer), part(Footer)];
|
||||
let named = SectionProps {
|
||||
start: SectionStart::Continuous,
|
||||
title_page: true,
|
||||
headers: HeaderSet { default: Some(ids[0]), first: Some(ids[1]), even: Some(ids[2]) },
|
||||
footers: HeaderSet { default: Some(ids[3]), first: Some(ids[4]), even: Some(ids[5]) },
|
||||
..Default::default()
|
||||
};
|
||||
let linked = SectionProps { headers: HeaderSet::default(), footers: HeaderSet::default(), ..named.clone() };
|
||||
for i in 0..n.saturating_sub(1) {
|
||||
let s = if i == 0 || !inherit { named.clone() } else { linked.clone() };
|
||||
d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().section = Some(Box::new(s));
|
||||
}
|
||||
d.last_section = if inherit { linked } else { named };
|
||||
d
|
||||
}
|
||||
|
||||
/// Finding 13: each section's headers and footers were found by walking back through the
|
||||
/// sections before it, for every section and again for every page. Linked to the previous
|
||||
/// section's, they must cost no more to lay out than named in each section.
|
||||
#[test]
|
||||
fn headers_inherited_through_many_sections_lay_out_in_linear_time() {
|
||||
let l = lay(§ions(200, true), &LayoutOptions::default());
|
||||
assert!(l.pages.iter().all(|p| !p.header.is_empty() && !p.footer.is_empty()), "the header and footer are inherited");
|
||||
let opts = LayoutOptions::default();
|
||||
assert_layout_linear("inherited headers", 1000, |n| sections(n, true), &opts);
|
||||
let steps = |inherit: bool| work::count(|| drop(std::hint::black_box(lay(§ions(1000, inherit), &opts))));
|
||||
let (named, inherited) = (steps(false), steps(true));
|
||||
assert!(inherited <= 2 * named, "1000 inheriting sections took {inherited} steps, named ones {named}");
|
||||
}
|
||||
|
||||
/// A header of `n` short paragraphs.
|
||||
fn tall_header(n: usize) -> (Document, u32) {
|
||||
let mut d = Document::from_text("Body");
|
||||
let paras = (0..n).map(|i| wordcraft_doc::para_block(Paragraph::with_text(&format!("h{i}"), CharProps::default()))).collect();
|
||||
let id = d.add_part(wordcraft_doc::PartKind::Header, paras);
|
||||
d.last_section.headers.default = Some(id);
|
||||
(d, id)
|
||||
}
|
||||
|
||||
/// Finding 28: selecting across a header looked through the whole page for each paragraph.
|
||||
#[test]
|
||||
fn selecting_a_long_header_takes_linear_time() {
|
||||
// The header laid out, and the selection from its first paragraph to its last.
|
||||
let laid = |n: usize| {
|
||||
let (d, id) = tall_header(n);
|
||||
let l = lay(&d, &LayoutOptions::default());
|
||||
let ends = (Pos::new(StoryRef::Part(id), Path::top(0), 0), Pos::new(StoryRef::Part(id), Path::top(n - 1), 2));
|
||||
(d, l, ends)
|
||||
};
|
||||
let (d, l, (a, b)) = laid(SCALE * 300);
|
||||
assert!(l.selection_rects(&d, &a, &b, 0).len() >= SCALE * 300, "a rectangle per paragraph");
|
||||
assert_linear_steps("header selection", 300, laid, |(d, l, (a, b))| drop(std::hint::black_box(l.selection_rects(d, a, b, 0))));
|
||||
}
|
||||
|
||||
/// An endnote of `n` paragraphs, a few lines each, continued over many pages.
|
||||
fn long_endnote(n: usize) -> (Document, u32) {
|
||||
let mut d = Document::from_text("Body");
|
||||
// One huge letter to a paragraph, one line high enough for a page, so the pages are as many as
|
||||
// the paragraphs and each paragraph costs next to nothing.
|
||||
let huge = CharProps { size: Some(400.0), ..Default::default() };
|
||||
let paras = (0..n).map(|_| wordcraft_doc::para_block(Paragraph::with_text("n", huge.clone()))).collect();
|
||||
let id = d.add_part(wordcraft_doc::PartKind::Endnote, paras);
|
||||
let r = InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Endnote, id, custom: String::new() };
|
||||
d.insert_object(&Pos::body(0, 4), r, &Default::default()).unwrap();
|
||||
(d, id)
|
||||
}
|
||||
|
||||
/// #3866: selecting across an endnote over many pages probed the pages from the first for each
|
||||
/// paragraph (paragraphs x pages).
|
||||
#[test]
|
||||
fn selecting_a_long_endnote_takes_linear_time() {
|
||||
let laid = |n: usize| {
|
||||
let (d, id) = long_endnote(n);
|
||||
let l = lay(&d, &LayoutOptions::default());
|
||||
let ends = (Pos::new(StoryRef::Part(id), Path::top(0), 0), Pos::new(StoryRef::Part(id), Path::top(n - 1), 2));
|
||||
(d, l, ends)
|
||||
};
|
||||
let (d, l, (a, b)) = laid(SCALE * 20);
|
||||
assert!(l.pages.len() >= SCALE * 20 / 2, "the endnote runs over many pages: {}", l.pages.len());
|
||||
assert!(l.selection_rects(&d, &a, &b, 0).len() >= SCALE * 20, "a rectangle per paragraph");
|
||||
assert_linear_steps("endnote selection", 20, laid, |(d, l, (a, b))| drop(std::hint::black_box(l.selection_rects(d, a, b, 0))));
|
||||
}
|
||||
|
||||
/// A table of `n` rows of 60 cells, each row exactly half a point high, so every cell clips its
|
||||
/// (empty) paragraph and the whole table fits on the first page.
|
||||
fn clipped_table(n: usize) -> Document {
|
||||
let mut t = Table::new(n, 60, 468.0);
|
||||
for row in &mut t.rows {
|
||||
row.props.height = Some(0.5);
|
||||
row.props.height_rule = HeightRule::Exact;
|
||||
}
|
||||
let mut d = Document::from_text("x");
|
||||
d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(t)).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// Finding 1: drawing a line in a clipped cell looked through every clipped cell on the page.
|
||||
#[test]
|
||||
fn drawing_many_clipped_cells_takes_linear_time() {
|
||||
let laid = |n: usize| {
|
||||
let d = clipped_table(n);
|
||||
let l = lay(&d, &LayoutOptions::default());
|
||||
(d, l)
|
||||
};
|
||||
let opts = display::DisplayOptions::default();
|
||||
let draw = |(d, l): &(Document, DocLayout)| l.pages.first().map(|p| display::page_display(d, p, &opts)).unwrap_or_default();
|
||||
let clips = draw(&laid(SCALE * 16)).iter().filter(|i| matches!(i, display::Draw::Clip { .. })).count();
|
||||
assert_eq!(clips, SCALE * 16 * 60, "every cell's line is clipped, all on the first page");
|
||||
assert_linear_steps("clipped cells", 16, laid, |dl| drop(std::hint::black_box(draw(dl))));
|
||||
}
|
||||
|
||||
/// `n` words, each inserted by its own author, at a size that fits them all on the first page.
|
||||
fn tracked_words(n: usize) -> Document {
|
||||
let mut d = Document::from_text("x");
|
||||
let p = Paragraph {
|
||||
text: "word ".repeat(n),
|
||||
runs: (0..n).map(|i| Run { len: 5, props: CharProps { ins: u32::try_from(i).ok(), size: Some(1.0), ..Default::default() } }).collect(),
|
||||
..Paragraph::with_text("", CharProps::default())
|
||||
};
|
||||
d.body = vec![wordcraft_doc::para_block(p)];
|
||||
d.revisions = (0..n).map(|i| wordcraft_doc::Revision::new(wordcraft_doc::RevisionKind::Insert, format!("Author {i}"), "")).collect();
|
||||
d
|
||||
}
|
||||
|
||||
/// Finding 30 (#3706): every tracked run ranked its author among all the document's revisions.
|
||||
#[test]
|
||||
fn drawing_tracked_changes_by_many_authors_takes_linear_time() {
|
||||
let laid = |n: usize| {
|
||||
let d = tracked_words(n);
|
||||
let l = lay(&d, &LayoutOptions::default());
|
||||
(d, l)
|
||||
};
|
||||
assert_eq!(laid(SCALE * 1000).1.pages.len(), 1);
|
||||
let opts = display::DisplayOptions { markup: true, ..Default::default() };
|
||||
let draw = |(d, l): &(Document, DocLayout)| l.pages.first().map(|p| display::page_display(d, p, &opts)).unwrap_or_default();
|
||||
// Every run is drawn in its author's colour, so the authors are ranked at least once.
|
||||
assert_linear_steps("tracked runs", 1000, laid, |dl| drop(std::hint::black_box(draw(dl))));
|
||||
}
|
||||
|
||||
/// Each author keeps the colour of their first revision's place among the authors.
|
||||
#[test]
|
||||
fn tracked_changes_colour_by_author_in_order_of_appearance() {
|
||||
let mut d = tracked_words(3);
|
||||
// Words 0 and 2 by Ana, word 1 by Ben: Ana first, Ben second.
|
||||
for (r, a) in d.revisions.iter_mut().zip(["Ana", "Ben", "Ana"]) {
|
||||
r.author = a.into();
|
||||
}
|
||||
let l = lay(&d, &LayoutOptions::default());
|
||||
let opts = display::DisplayOptions { markup: true, ..Default::default() };
|
||||
let colours: Vec<_> = display::page_display(&d, &l.pages[0], &opts)
|
||||
.into_iter()
|
||||
.filter_map(|i| if let display::Draw::Glyphs { color, .. } = i { Some(color) } else { None })
|
||||
.collect();
|
||||
let (ana, ben) = (display::revision_color(0), display::revision_color(1));
|
||||
assert_ne!(ana, ben);
|
||||
assert_eq!(colours, [ana, ben, ana]);
|
||||
}
|
||||
|
||||
/// Finding 30 (#3706): every comment balloon ranked its author among all the comments.
|
||||
#[test]
|
||||
fn balloons_for_many_authors_lay_out_in_linear_time() {
|
||||
let doc = |n: usize| {
|
||||
let mut d = Document::from_text(&"word ".repeat(n));
|
||||
for i in (0..n).rev() {
|
||||
let part = d.add_part(wordcraft_doc::PartKind::Comment, vec![wordcraft_doc::para_block(Paragraph::with_text("c", CharProps::default()))]);
|
||||
let id = u32::try_from(i).unwrap();
|
||||
d.comments.insert(id, wordcraft_doc::Comment { author: format!("Author {i}"), part, ..Default::default() });
|
||||
let p = CharProps::default();
|
||||
d.insert_object(&Pos::body(0, 5 * i + 4), InlineObject::CommentEnd { id }, &p).unwrap();
|
||||
d.insert_object(&Pos::body(0, 5 * i), InlineObject::CommentStart { id }, &p).unwrap();
|
||||
}
|
||||
d
|
||||
};
|
||||
let opts = LayoutOptions { comment_balloons: true, ..Default::default() };
|
||||
assert_layout_linear("comment balloons", 120, doc, &opts);
|
||||
}
|
||||
|
||||
/// `n` one-word paragraphs, each inserted by its own author, small enough for one page.
|
||||
fn tracked_paras(n: usize) -> Document {
|
||||
let mut d = Document::from_text("x");
|
||||
d.body = (0..n)
|
||||
.map(|i| {
|
||||
let run = Run { len: 1, props: CharProps { ins: u32::try_from(i).ok(), size: Some(1.0), ..Default::default() } };
|
||||
let mut p = Paragraph { runs: vec![run], ..Paragraph::with_text("w", CharProps::default()) };
|
||||
p.mark.size = Some(1.0);
|
||||
p.props.space_before = Some(0.0);
|
||||
p.props.space_after = Some(0.0);
|
||||
wordcraft_doc::para_block(p)
|
||||
})
|
||||
.collect();
|
||||
d.revisions = (0..n).map(|i| wordcraft_doc::Revision::new(wordcraft_doc::RevisionKind::Insert, format!("Author {i}"), "")).collect();
|
||||
d
|
||||
}
|
||||
|
||||
/// Draws every item of the first page on its own, as the PDF export does.
|
||||
fn draw_items((d, l): &(Document, DocLayout), opts: &display::DisplayOptions) {
|
||||
let Some(p) = l.pages.first() else { return };
|
||||
let shown = display::ItemDisplay::new(d, p);
|
||||
for it in p.header.iter().chain(&p.footer).chain(&p.items) {
|
||||
std::hint::black_box(shown.draws(it, opts));
|
||||
}
|
||||
}
|
||||
|
||||
/// #3866: the PDF export drew each item with a `page_display` of its own, which ranked the revision
|
||||
/// authors again for every item with a tracked change (lines x revisions).
|
||||
#[test]
|
||||
fn drawing_items_one_by_one_ranks_the_authors_once() {
|
||||
let laid = |n: usize| {
|
||||
let d = tracked_paras(n);
|
||||
let l = lay(&d, &LayoutOptions::default());
|
||||
(d, l)
|
||||
};
|
||||
assert_eq!(laid(SCALE * 40).1.pages.len(), 1);
|
||||
let opts = display::DisplayOptions { markup: true, ..Default::default() };
|
||||
assert_linear_steps("items drawn one by one", 40, laid, |dl| draw_items(dl, &opts));
|
||||
}
|
||||
|
||||
/// #3866: the same for the clipped cells, which each item's `page_display` collected again
|
||||
/// (lines x clipped cells).
|
||||
#[test]
|
||||
fn drawing_items_one_by_one_collects_the_clipped_cells_once() {
|
||||
let laid = |n: usize| {
|
||||
let d = clipped_table(n);
|
||||
let l = lay(&d, &LayoutOptions::default());
|
||||
(d, l)
|
||||
};
|
||||
let opts = display::DisplayOptions::default();
|
||||
assert_linear_steps("clipped cells, items one by one", 16, laid, |dl| draw_items(dl, &opts));
|
||||
}
|
||||
|
||||
/// #3866: a comment's balloon is in its author's colour, authors numbered in order of appearance.
|
||||
#[test]
|
||||
fn balloons_are_in_their_authors_colours() {
|
||||
let mut d = Document::from_text("one two three");
|
||||
for (i, a) in ["Ana", "Ben", "Ana"].into_iter().enumerate() {
|
||||
let part = d.add_part(wordcraft_doc::PartKind::Comment, vec![wordcraft_doc::para_block(Paragraph::with_text("c", CharProps::default()))]);
|
||||
let id = u32::try_from(i).unwrap();
|
||||
d.comments.insert(id, wordcraft_doc::Comment { author: a.into(), part, ..Default::default() });
|
||||
let p = CharProps::default();
|
||||
d.insert_object(&Pos::body(0, 0), InlineObject::CommentEnd { id }, &p).unwrap();
|
||||
d.insert_object(&Pos::body(0, 0), InlineObject::CommentStart { id }, &p).unwrap();
|
||||
}
|
||||
let l = lay(&d, &LayoutOptions { comment_balloons: true, ..Default::default() });
|
||||
// Each card's stripe is a 3 pt wide fill in the author's colour; the cards run down the page.
|
||||
let mut stripes: Vec<_> = l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|it| match it {
|
||||
Placed::Fill { rect, color } if rect.w == 3.0 => Some((rect.y, *color)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
stripes.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let (ana, ben) = (display::revision_color(0), display::revision_color(1));
|
||||
assert_ne!(ana, ben);
|
||||
assert_eq!(stripes.iter().map(|s| s.1).collect::<Vec<_>>(), [ana, ben, ana]);
|
||||
}
|
||||
|
||||
/// A page with tracked changes by several authors in its body and header, and exact-height
|
||||
/// tables, one nested in a cell of another, whose cells clip their lines.
|
||||
fn mixed_page() -> (Document, DocLayout) {
|
||||
let mut d = tracked_paras(6);
|
||||
let mut hp = Paragraph::with_text("head", CharProps::default());
|
||||
hp.runs = vec![Run { len: 4, props: CharProps { ins: Some(2), ..Default::default() } }];
|
||||
let id = d.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(hp)]);
|
||||
d.last_section.headers.default = Some(id);
|
||||
let exact = |rows, cols| {
|
||||
let mut t = Table::new(rows, cols, 200.0);
|
||||
for row in &mut t.rows {
|
||||
row.props.height = Some(0.5);
|
||||
row.props.height_rule = HeightRule::Exact;
|
||||
}
|
||||
t
|
||||
};
|
||||
// A cell with text in it holds a nested table.
|
||||
let mut outer = exact(2, 2);
|
||||
outer.rows[0].cells[0].blocks.push(std::sync::Arc::new(Block::Table(exact(2, 2))));
|
||||
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(outer)).unwrap();
|
||||
d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(exact(2, 3))).unwrap();
|
||||
let l = lay(&d, &LayoutOptions::default());
|
||||
(d, l)
|
||||
}
|
||||
|
||||
/// What drawing `it` on a page of just it (and every clipped cell, for a line) gave before the
|
||||
/// page's authors and clipped cells were shared: a `page_display` of its own.
|
||||
fn drawn_alone(d: &Document, page: &Page, it: &Placed, opts: &display::DisplayOptions) -> Vec<display::Draw> {
|
||||
let mut items = vec![it.clone()];
|
||||
if matches!(it, Placed::Lines { .. }) {
|
||||
let cells = page.header.iter().chain(&page.footer).chain(&page.items).filter(|c| matches!(c, Placed::Cell { clip: true, .. }));
|
||||
items.extend(cells.cloned());
|
||||
}
|
||||
let one = Page { w: page.w, h: page.h, section: page.section, number: page.number, items, body: page.body, ..Default::default() };
|
||||
display::page_display(d, &one, opts)
|
||||
}
|
||||
|
||||
/// #3866: drawing the items of a page one by one with the page's shared authors and clipped
|
||||
/// cells gives what a `page_display` of each alone gave.
|
||||
#[test]
|
||||
fn drawing_items_one_by_one_matches_a_page_display_of_each() {
|
||||
let (d, l) = mixed_page();
|
||||
let page = &l.pages[0];
|
||||
let clipped = page.items.iter().filter(|c| matches!(c, Placed::Cell { clip: true, .. })).count();
|
||||
assert!(clipped >= 8, "clipped cells, nested ones among them: {clipped}");
|
||||
assert!(!page.header.is_empty(), "a header on the page");
|
||||
for markup in [true, false] {
|
||||
let opts = display::DisplayOptions { markup, ..Default::default() };
|
||||
let shown = display::ItemDisplay::new(&d, page);
|
||||
let (mut clips, mut coloured) = (0, 0);
|
||||
for it in page.header.iter().chain(&page.footer).chain(&page.items) {
|
||||
let (got, want) = (shown.draws(it, &opts), drawn_alone(&d, page, it, &opts));
|
||||
assert_eq!(format!("{got:?}"), format!("{want:?}"), "markup {markup}: {it:?}");
|
||||
clips += got.iter().filter(|x| matches!(x, display::Draw::Clip { .. })).count();
|
||||
coloured += got.iter().filter(|x| matches!(x, display::Draw::Glyphs { color, .. } if *color != Rgb(0, 0, 0))).count();
|
||||
}
|
||||
assert!(clips > 0, "some lines are clipped");
|
||||
assert!(!markup || coloured > 0, "some tracked text is in an author's colour");
|
||||
}
|
||||
}
|
||||
|
||||
/// #3866: a header paragraph on the hinted page is found there, without a walk of every page of a
|
||||
/// long document to find the first page that shows it.
|
||||
#[test]
|
||||
fn selecting_a_header_on_the_hinted_page_does_not_walk_every_page() {
|
||||
let laid = |n: usize| {
|
||||
let mut d = Document::from_text("n");
|
||||
let huge = CharProps { size: Some(400.0), ..Default::default() };
|
||||
d.body = (0..n).map(|_| wordcraft_doc::para_block(Paragraph::with_text("n", huge.clone()))).collect();
|
||||
let id = d.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(Paragraph::with_text("head", CharProps::default()))]);
|
||||
d.last_section.headers.default = Some(id);
|
||||
let l = lay(&d, &LayoutOptions::default());
|
||||
let ends = (Pos::new(StoryRef::Part(id), Path::top(0), 0), Pos::new(StoryRef::Part(id), Path::top(0), 2));
|
||||
(d, l, ends)
|
||||
};
|
||||
let steps = |n: usize| {
|
||||
let (d, l, (a, b)) = laid(n);
|
||||
assert!(l.pages.len() >= n, "a page to each paragraph: {}", l.pages.len());
|
||||
work::count(|| drop(std::hint::black_box(l.selection_rects(&d, &a, &b, 0))))
|
||||
};
|
||||
let (small, big) = (steps(20), steps(160));
|
||||
eprintln!("hinted header: {small} steps for 20 pages, {big} for 160");
|
||||
assert!(small > 0 && big <= small + 8, "the pages aren't walked: {small} → {big}");
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! A no-break space is laid out as wide as an ordinary space, whatever the font's own U+00A0
|
||||
//! glyph measures (Lucida Bright draws it twice as wide).
|
||||
|
||||
use super::*;
|
||||
use super::{Document, LayoutCache, LayoutOptions, Placed, layout};
|
||||
|
||||
/// Inter with its U+00A0 glyph twice as wide as its space, under the family name "Wnbsp".
|
||||
/// Built from the bundled font so the test needs no installed font.
|
||||
@@ -37,7 +37,8 @@ fn wide_nbsp_family() -> &'static str {
|
||||
/// The width of the first line of `text` set in the wide-nbsp font.
|
||||
fn line_width(text: &str) -> f32 {
|
||||
let chr = wordcraft_doc::CharProps { font: Some(wide_nbsp_family().into()), size: Some(12.0), ..Default::default() };
|
||||
let d = Document { body: vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, chr))], ..Document::new() };
|
||||
let mut d = Document::new();
|
||||
d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, chr))];
|
||||
let mut c = LayoutCache::new();
|
||||
let l = layout(&d, &mut c, &LayoutOptions::default());
|
||||
for p in &l.pages {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! #3977: floating tables include a line's bottom edge; shapes keep a strict boundary.
|
||||
//! The expected distinction is cyril265's Word for Mac 16.114.1 observation in the issue.
|
||||
|
||||
use super::*;
|
||||
use super::{Arc, Block, Document, LayoutCache, LayoutOptions, Placed, StoryRef, layout};
|
||||
use wordcraft_doc::para::{Anchor, Float, InlineObject, ShapeKind, Wrap};
|
||||
use wordcraft_doc::props::{HeightRule, LineSpacing, ParaProps, TableFloat};
|
||||
use wordcraft_doc::{CharProps, Paragraph, Pos, Table};
|
||||
|
||||
Loaded 100 of 171 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user