diff --git a/Cargo.lock b/Cargo.lock index dc052fa739..b8a2c6be09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 80b5bcc1cd..fe7d7c9c71 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/NOTICE b/NOTICE index 7fd6e61209..82e79a0a98 100644 --- a/NOTICE +++ b/NOTICE @@ -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. diff --git a/README.md b/README.md index 42340c3133..b0445a01eb 100644 --- a/README.md +++ b/README.md @@ -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--windows-x64.msi` | `wordcraft--windows-x64-portable.zip` | -| arm64 (Snapdragon and other ARM PCs) | `wordcraft--windows-arm64.msi` | `wordcraft--windows-arm64-portable.zip` | -| x86 (32-bit) | `wordcraft--windows-x86.msi` | `wordcraft--windows-x86-portable.zip` | +| Build | Installer (all users) | Installer (just me) | Portable | +|---|---|---|---| +| x64 (64-bit Intel/AMD) | `wordcraft--windows-x64.msi` | `wordcraft--windows-user-x64.msi` | `wordcraft--windows-x64-portable.zip` | +| arm64 (Snapdragon and other ARM PCs) | `wordcraft--windows-arm64.msi` | `wordcraft--windows-user-arm64.msi` | `wordcraft--windows-arm64-portable.zip` | +| x86 (32-bit) | `wordcraft--windows-x86.msi` | `wordcraft--windows-user-x86.msi` | `wordcraft--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 diff --git a/ROADMAP.md b/ROADMAP.md index 3f2761bb44..ff3e9f8303 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 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: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-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: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (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--windows-user-.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) | diff --git a/apps/wordcraft-cli/src/main.rs b/apps/wordcraft-cli/src/main.rs index 752f9f68b2..f9a8299ab5 100644 --- a/apps/wordcraft-cli/src/main.rs +++ b/apps/wordcraft-cli/src/main.rs @@ -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::().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>(args: &[S]) -> Result<(), Failure> { let script_path = script_operand(args); let (args, file) = text_args(args)?; @@ -482,10 +488,14 @@ fn run>(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>(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()); diff --git a/apps/wordcraft-cli/tests/documents.rs b/apps/wordcraft-cli/tests/documents.rs new file mode 100644 index 0000000000..e62a880d7b --- /dev/null +++ b/apps/wordcraft-cli/tests/documents.rs @@ -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}"); +} diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index f1d27bc589..85227c169c 100644 --- a/apps/wordcraft/Cargo.toml +++ b/apps/wordcraft/Cargo.toml @@ -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"] } diff --git a/apps/wordcraft/src/apple_events.rs b/apps/wordcraft/src/apple_events.rs index e952a3dfcf..0eae596a9d 100644 --- a/apps/wordcraft/src/apple_events.rs +++ b/apps/wordcraft/src/apple_events.rs @@ -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(); + } } } } diff --git a/apps/wordcraft/src/control_server.rs b/apps/wordcraft/src/control_server.rs index 986332419c..2b8f4137d7 100644 --- a/apps/wordcraft/src/control_server.rs +++ b/apps/wordcraft/src/control_server.rs @@ -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 { +fn random_key() -> Result { 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 { /// 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, } @@ -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) { self.deadline = d; if d.is_none() { diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 050c5c86ef..bc66c94115 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -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, @@ -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, #[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), ))) diff --git a/apps/wordcraft/src/windows.rs b/apps/wordcraft/src/windows.rs deleted file mode 100644 index a98659c591..0000000000 --- a/apps/wordcraft/src/windows.rs +++ /dev/null @@ -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 `/windows/.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, -} - -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 `.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 { - 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>, -} - -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>>, - title: Arc>, - out: Sender<(u64, PeerMessage)>, - incoming: Receiver, -} - -impl WindowHost for Windows { - fn id(&self) -> u64 { - self.id - } - fn others(&self) -> Vec { - 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 { - 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 { - 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 `.json` whole (a temporary file renamed over it), unless the window has closed. -fn write_entry(dir: &Path, e: &Entry, closed: &Mutex) -> 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 { - 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 { - 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::(&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>, - others: Arc>>, - title: Arc>, - /// The other windows at the last refresh. - known: Vec, - /// When this window's entry was last written. - written: Instant, -} - -impl Worker { - fn run(mut self, queue: &Receiver<(u64, PeerMessage)>) { - let mut refreshed: Option = 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::()).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, ctx: egui::Context) { - let key: Arc = 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, 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, &'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::(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::>(), [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))] - ); - } -} diff --git a/crates/doc/Cargo.toml b/crates/doc/Cargo.toml index 9ab62d34d8..aff28f8d91 100644 --- a/crates/doc/Cargo.toml +++ b/crates/doc/Cargo.toml @@ -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 } diff --git a/crates/doc/src/cfb_guard.rs b/crates/doc/src/cfb_guard.rs new file mode 100644 index 0000000000..ec32e4a1a8 --- /dev/null +++ b/crates/doc/src/cfb_guard.rs @@ -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 = (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 = 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 { + 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 { + 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()); + } +} diff --git a/crates/doc/src/control.rs b/crates/doc/src/control.rs index b9eb2c6a99..31ff6814af 100644 --- a/crates/doc/src/control.rs +++ b/crates/doc/src/control.rs @@ -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> { 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 = + 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() }; diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 9107139aff..64a75b4446 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -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) { 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, + /// 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, + /// 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::(), false); + each_para(b, 0, &mut |p| { + let items = p + .runs + .len() + .saturating_mul(std::mem::size_of::()) + .saturating_add(p.objects.len().saturating_mul(std::mem::size_of::())); + 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::()).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::().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::(), 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(v: &T) -> usize { + struct Count(usize); + impl std::io::Write for Count { + fn write(&mut self, b: &[u8]) -> std::io::Result { + 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 { 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, depth: usize, budget: &mut usize) { + fn adopt_text_boxes(&mut self, blocks: &mut [Block], parts: &BTreeMap, 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, depth: usize, budget: &mut usize) -> Option { - 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, depth: usize, budget: &mut BoxBudget) -> Option { + 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 = 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::>()); + 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>, 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 = 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> = p.objects.iter().map(|o| if let InlineObject::Shape { story, .. } = o { *story } else { None }).collect(); + assert_eq!(got.iter().map(Option::is_some).collect::>(), [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 = 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] diff --git a/crates/doc/src/fields.rs b/crates/doc/src/fields.rs index 29b050e603..fec8900fb7 100644 --- a/crates/doc/src/fields.rs +++ b/crates/doc/src/fields.rs @@ -43,12 +43,12 @@ fn scan(story: StoryRef, bl: &Blocks, prefix: &mut Vec, 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() } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index b3ac326aed..6090ecacd0 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -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, + /// 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>, 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>>) { + 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>>, bytes: &Vec) -> Option { + 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, 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 = 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 = Vec::new(); + // Open ranges by id, latest last (an end closes the latest open range of its id). + let mut open: BTreeMap> = 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; diff --git a/crates/doc/src/math.rs b/crates/doc/src/math.rs index d620303484..f4ee463810 100644 --- a/crates/doc/src/math.rs +++ b/crates/doc/src/math.rs @@ -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::()); + 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::()); + 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(_)])); + } } diff --git a/crates/doc/src/math_linear.rs b/crates/doc/src/math_linear.rs index 1820c248d1..3f077f3778 100644 --- a/crates/doc/src/math_linear.rs +++ b/crates/doc/src/math_linear.rs @@ -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(' '))]; diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index 9add66c65a..94738db5d5 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -1089,6 +1089,12 @@ impl Paragraph { pub fn object_offsets(&self) -> Vec { 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 + '_ { + 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 { @@ -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); diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index d3c0280676..76911dd4f8 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -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::() + 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)) } diff --git a/crates/doc/src/test_log.rs b/crates/doc/src/test_log.rs new file mode 100644 index 0000000000..745ce21fe1 --- /dev/null +++ b/crates/doc/src/test_log.rs @@ -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> = 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(f: impl FnOnce() -> T) -> (T, Vec) { + // 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()))) +} diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 18e69c8325..67256fd78c 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -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) -> 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 = 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 = (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 = (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 = (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:?}"); + } +} diff --git a/crates/docbin/Cargo.toml b/crates/docbin/Cargo.toml index 87e9ea1fd5..c819c33885 100644 --- a/crates/docbin/Cargo.toml +++ b/crates/docbin/Cargo.toml @@ -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 } diff --git a/crates/docbin/src/drawing.rs b/crates/docbin/src/drawing.rs index 461d6b372c..f5c02699f3 100644 --- a/crates/docbin/src/drawing.rs +++ b/crates/docbin/src/drawing.rs @@ -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)); diff --git a/crates/docbin/src/lib.rs b/crates/docbin/src/lib.rs index b9179c7d28..7734c72220 100644 --- a/crates/docbin/src/lib.rs +++ b/crates/docbin/src/lib.rs @@ -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, + 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::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> = 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>, 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 { @@ -102,7 +193,7 @@ pub fn read(bytes: &[u8]) -> Result { /// 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 { - 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) -> Result) -> Result { /// 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, + /// 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 { +fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -> Vec { 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 = None; + let mut memo: Option> = 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>, } +/// 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::() 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 = 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 { /// 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>) { 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, + 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); diff --git a/crates/docbin/src/media.rs b/crates/docbin/src/media.rs index 52e0aa9109..da3656d513 100644 --- a/crates/docbin/src/media.rs +++ b/crates/docbin/src/media.rs @@ -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>, /// BLIP store index → media key (`None`: unreadable, not retried). by_bse: HashMap>, + /// (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>, /// The media entries, merged into the document at the end. pub(crate) media: BTreeMap>>, 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 { - 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 { + 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, ext: &str, placeholder: bool) -> Option { 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, 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 { +/// the BLIP is decoded once per record and charged to the decode budget. +fn locate(data: &[u8], fc_pic: u32, pics: &mut Pictures) -> Option { 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 { 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 { } /// 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, 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 diff --git a/crates/docbin/src/officeart.rs b/crates/docbin/src/officeart.rs index 12d4a7d2e1..6d35d929d9 100644 --- a/crates/docbin/src/officeart.rs +++ b/crates/docbin/src/officeart.rs @@ -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 { +#[cfg(test)] +pub(crate) fn fbse_blip(fbse: &[u8], delay: &[u8], limit: usize, work: &mut Decode) -> Option { + 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 { +/// 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 { // 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 { 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 { 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 { /// 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 { +/// 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 { 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 { 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 { { Some(Blip { bytes: png.bytes, ext: "png", placeholder: false }) } - _ => Some(Blip { bytes: bmp, ext: "bmp", placeholder: true }), + _ => placeholder(bmp), } } diff --git a/crates/docbin/src/piece.rs b/crates/docbin/src/piece.rs index a7274c9d9d..d52f968b7a 100644 --- a/crates/docbin/src/piece.rs +++ b/crates/docbin/src/piece.rs @@ -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() { diff --git a/crates/docbin/src/tests.rs b/crates/docbin/src/tests.rs index 0a66c157b3..e23cd50241 100644 --- a/crates/docbin/src/tests.rs +++ b/crates/docbin/src/tests.rs @@ -1732,9 +1732,9 @@ fn picture_bytes_are_capped_per_document() { let data: Vec = [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 { + 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::>(); + 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]); +} diff --git a/crates/docbin/src/tests/drawing.rs b/crates/docbin/src/tests/drawing.rs index 4007408e11..b754206320 100644 --- a/crates/docbin/src/tests/drawing.rs +++ b/crates/docbin/src/tests/drawing.rs @@ -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::>()); + 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::>())]); + let members: Vec> = (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 = (1..=n + 1).collect(); + let spas: Vec = (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 = + 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 { + 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 { + 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> = (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> = 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::>(); + 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 { + 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 { + 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 { + 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 { + 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); +} diff --git a/crates/docx/Cargo.toml b/crates/docx/Cargo.toml index 4dd1d510c0..5e16d7e4c2 100644 --- a/crates/docx/Cargo.toml +++ b/crates/docx/Cargo.toml @@ -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 } diff --git a/crates/docx/src/chart_spec.rs b/crates/docx/src/chart_spec.rs index 698db44c0d..4097002b6b 100644 --- a/crates/docx/src/chart_spec.rs +++ b/crates/docx/src/chart_spec.rs @@ -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 { @@ -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("").count(); + let points = x.matches("(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(" 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!( + "Injected", + "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!(""); + let rels = format!( + "{}{}", + rel("rId1", crate::package::rt::OFFICE_DOC, "word/document.xml"), + rel("rId2", crate::package::rt::CUSTOM_XML, "customXml/item1.xml") + ); + let 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!("").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 ["", "\nt & \n", ""] { + assert!(xml_fragment(ct, ok.as_bytes()).is_some(), "{ok}"); + } + for bad in [ + "", + "", + "", + "", + "text", + "&", + "", + "", + "", + "", + "", + ] { + 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); diff --git a/crates/docx/src/fonts.rs b/crates/docx/src/fonts.rs index f63930b571..66e92ae0b8 100644 --- a/crates/docx/src/fonts.rs +++ b/crates/docx/src/fonts.rs @@ -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 { + 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 { + 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 { 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 { }, 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#""#, + ); + 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#""#)); + manifest.push_str(&format!("R\t{FONT_TABLE_PART}\trId{i}\tfont\t{part}\tI\n")); + } + table.push_str(""); + 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"); + } +} diff --git a/crates/docx/src/lib.rs b/crates/docx/src/lib.rs index 27cb0e6da1..72cfdebfa9 100644 --- a/crates/docx/src/lib.rs +++ b/crates/docx/src/lib.rs @@ -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; diff --git a/crates/docx/src/package.rs b/crates/docx/src/package.rs index f98a3a709f..a7511fd2bc 100644 --- a/crates/docx/src/package.rs +++ b/crates/docx/src/package.rs @@ -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>>, + /// 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, } 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>) -> 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>>) -> Package { + let mut lower: HashMap = 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>> { - 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>>) -> HashSet { + 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#""#; + 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 = (0..20).map(|i| format!("p{i}.bin")).collect(); + let entries: Vec<(&str, Vec)> = 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 = 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"".to_vec())]); diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index 414993b97c..3902f81003 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -1363,6 +1363,10 @@ impl Reader<'_> { let mut by_para: HashMap = HashMap::new(); let mut next_id = 0u32; let mut entries: BTreeMap = 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 = 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::() { 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 diff --git a/crates/docx/src/test_log.rs b/crates/docx/src/test_log.rs new file mode 100644 index 0000000000..745ce21fe1 --- /dev/null +++ b/crates/docx/src/test_log.rs @@ -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> = 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(f: impl FnOnce() -> T) -> (T, Vec) { + // 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()))) +} diff --git a/crates/docx/src/write/embed.rs b/crates/docx/src/write/embed.rs index 6e04956143..405b901e4f 100644 --- a/crates/docx/src/write/embed.rs +++ b/crates/docx/src/write/embed.rs @@ -48,6 +48,10 @@ pub(crate) struct EmbedWriter<'d> { pub parts: Vec<(String, Vec, 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>, + /// 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::from_utf8_lossy(roots) + .lines() + .take(MAX_EMBEDDED_RELS * 2) + .filter_map(|line| match line.split('\t').collect::>().as_slice() { + [source, kind, target] if *source == "D" && rel_is(kind, rt::GLOSSARY) => Some(target.to_string()), + _ => None, + }) + .collect(); + let mut seen: HashSet = 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) -> Vec { 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> { + 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| { + 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| 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 = [("rId9".to_string(), "rId2".to_string()), ("rId2".to_string(), "rId3".to_string())].into_iter().collect(); diff --git a/crates/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs index b6a66186ad..36a46de645 100644 --- a/crates/docx/src/write/mod.rs +++ b/crates/docx/src/write/mod.rs @@ -272,7 +272,13 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result Vec { /// 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. diff --git a/crates/docx/tests/it/glossary.rs b/crates/docx/tests/it/glossary.rs index 36906ce43d..92e07a6514 100644 --- a/crates/docx/tests/it/glossary.rs +++ b/crates/docx/tests/it/glossary.rs @@ -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#"Added clause"#; + 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#"Nameless entrynoted"#; + // The comments part binds WordprocessingML to another prefix: names are matched by namespace. + let comments = format!( + r#"A note"# + ); + 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#"Added clause"#; + let utf16: Vec = [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#"Body"#).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(f: impl FnOnce() -> T) -> (T, Vec) { + use std::cell::RefCell; + struct Capture; + thread_local! { + static LINES: RefCell> = 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(); diff --git a/crates/docx/tests/it/malformed.rs b/crates/docx/tests/it/malformed.rs index 15a4b263c7..a714f27b36 100644 --- a/crates/docx/tests/it/malformed.rs +++ b/crates/docx/tests/it/malformed.rs @@ -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!(""); + for i in 0..n { + comments.push_str(&format!(r#""#)); + } + comments.push_str(""); + let doc = format!("body"); + let rels = format!(r#""#); + 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"); +} diff --git a/crates/docx/tests/it/passthrough.rs b/crates/docx/tests/it/passthrough.rs index 3f29bd50f1..65d4e3495e 100644 --- a/crates/docx/tests/it/passthrough.rs +++ b/crates/docx/tests/it/passthrough.rs @@ -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("")); } + +/// `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#""#, + 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".repeat(64 * 1024)); + let ct = CT.replace(" Vec { 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 diff --git a/crates/engine/src/cmd/font.rs b/crates/engine/src/cmd/font.rs index dfb7a48166..6160f84713 100644 --- a/crates/engine/src/cmd/font.rs +++ b/crates/engine/src/cmd/font.rs @@ -43,6 +43,15 @@ fn read_font_file(path: &str) -> Result, 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::>()})) } + +#[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); + } +} diff --git a/crates/engine/src/cmd/mailings.rs b/crates/engine/src/cmd/mailings.rs index 488b9bc4a1..bede814d14 100644 --- a/crates/engine/src/cmd/mailings.rs +++ b/crates/engine/src/cmd/mailings.rs @@ -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> { +/// 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> { 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>, row: &mut Vec) { + if row.iter().any(|x| !x.is_empty()) { + rows.push(std::mem::take(row)); + } else { + row.clear(); + } +} + +pub fn parse_csv(text: &str) -> Vec> { + 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> { 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> { } } 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})) } diff --git a/crates/engine/src/cmd/objects.rs b/crates/engine/src/cmd/objects.rs index b99c302522..7528319318 100644 --- a/crates/engine/src/cmd/objects.rs +++ b/crates/engine/src/cmd/objects.rs @@ -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 { let (w, h) = grown(img.width(), img.height(), border).ok_or_else(too_large)?; let mut out = canvas(w, h, color)?; diff --git a/crates/engine/src/cmd/options.rs b/crates/engine/src/cmd/options.rs index 74777db995..0c676b750a 100644 --- a/crates/engine/src/cmd/options.rs +++ b/crates/engine/src/cmd/options.rs @@ -263,6 +263,8 @@ fn folder_setting(key: &str, f: &str, local_only: bool) -> Result 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()))); } diff --git a/crates/engine/src/cmd/protect.rs b/crates/engine/src/cmd/protect.rs index f40c042c38..83b3c161f3 100644 --- a/crates/engine/src/cmd/protect.rs +++ b/crates/engine/src/cmd/protect.rs @@ -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")); } diff --git a/crates/engine/src/cmd/speech.rs b/crates/engine/src/cmd/speech.rs index 651f6d6369..fac23ac830 100644 --- a/crates/engine/src/cmd/speech.rs +++ b/crates/engine/src/cmd/speech.rs @@ -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() { diff --git a/crates/engine/src/cmd/templates.rs b/crates/engine/src/cmd/templates.rs index 60d4044a05..110427879c 100644 --- a/crates/engine/src/cmd/templates.rs +++ b/crates/engine/src/cmd/templates.rs @@ -185,17 +185,22 @@ fn load_other(name: &str, data: Option<&str>, base: Option<&Path>) -> Result 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 { 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"); } diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index ee7ebaafa5..9a17628664 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -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 { 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 { 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. diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 695501aea5..638f38b7bf 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -551,8 +551,7 @@ pub fn specs() -> Vec { 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 { - #[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 { } 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 { 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, 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 { if characters { diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 64ed300f4c..04f5b136d4 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -110,6 +110,10 @@ pub fn specs() -> Vec { .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 { 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 { ] } -/// 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()})) +} diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 3d1e808805..8e6fdaf171 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -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 { 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 { + 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 { // The names below the nearest existing folder, last first. let mut missing: Vec = 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(); diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 5af08451bf..960255030b 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -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 { + 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 { + 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 { 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) } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a3c004ac88..8188d58dc1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index 3306a9d619..ce210f5acc 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -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 { #[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 { #[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)> = 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 { + 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 { diff --git a/crates/engine/src/recovery/desktop.rs b/crates/engine/src/recovery/desktop.rs index 8ddd7bbb83..861f3ba294 100644 --- a/crates/engine/src/recovery/desktop.rs +++ b/crates/engine/src/recovery/desktop.rs @@ -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 Write for Limited { } } enum Job { - Save(Box), + /// Write `document`'s copy. + Save(Box, 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), } enum Outcome { Done, Discarded(String), - Loaded(Box, (u64, u64)), + Loaded(Box, Request), } -struct Event { +pub(crate) struct Event { documents: Vec, outcome: Result, /// The failure would repeat for the same document; see [`SaveError`]. permanent: bool, + /// The open document a failed save or discard was for. + document: Option, + /// The record a failed discard was for. + discard: Option, } +/// 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, + pub revision: Option, + pub due: Instant, + /// A failed discard of this document's copy is tried again from then on. + pub retry: Option, +} +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, 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, rx: mpsc::Receiver, + /// Every record the store found or wrote, this process's own included. pub documents: Vec, pub ready: bool, pub busy: bool, pub error: Option, - document: Option, - id: Option, - revision: Option, - 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, Request)>, } impl Recovery { pub fn start(root: PathBuf, wake: Arc) -> Result { @@ -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 { + 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 { + 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) -> Result { + 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 { + self.closed.iter().map(|(id, _)| id.as_str()) + } + /// Closed documents' records not yet queued for deletion. + pub fn take_closed(&mut self) -> Vec { + 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>, 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 { - 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) -> Option { + 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 { + let mut ids: Vec = 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 = 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]); } } diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index fbabadfc88..4e924f2970 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -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 { + 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>, Vec>, bool, u64, Option); + +/// 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, + Arc, + bool, + Option, + (bool, wordcraft_layout::HideDeleted, bool), + NumeralMode, + Option, + (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; 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, 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>, - /// 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, - Arc, - bool, - Option, - (bool, wordcraft_layout::HideDeleted, bool), - NumeralMode, - Option, - )>, + layout: LayoutSlots, /// The word being typed, left without a spelling squiggle ([`TypedWord`]). typing: Option, /// 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, + /// The recovery worker every open document's copy is kept through (desktop hosts only). #[cfg(not(target_arch = "wasm32"))] pub(crate) recovery: Option, + /// 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, @@ -564,6 +632,12 @@ pub struct Session { edit_marks: Vec, /// 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, + /// Every open document's id, the active one's too, in the order they were opened. + order: Vec, + /// 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) -> 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| { + 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>, Option>) = (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 { + // 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 = 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, + before_doc: Option, + blocks_before: Option>, + default_language_before: Option, + pending_application_change_before: bool, + ) -> Result { + // 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 = 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 diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs new file mode 100644 index 0000000000..831a68fc66 --- /dev/null +++ b/crates/engine/src/session/documents.rs @@ -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, + pub(super) path: Option, + pub(super) file_name: Option, + pub(super) dirty: bool, + pub(super) pending_move: Option, + pub(super) find: FindState, + pub(super) goal_x: Option, + pub(super) page_hint: usize, + pub(super) new_header_parts: Vec, + pub(super) hf_return: Option, + pub(super) history: Vec>, + pub(super) undo_evicted: u64, + pub(super) redo: Vec>, + pub(super) typing_open: bool, + pub(super) join_next: bool, + pub(super) equation_join_next: bool, + pub(super) ime_text_join: Option, + pub(super) join_anchor: bool, + pub(super) autocap: Option, + 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, + pub(super) originals: std::collections::HashMap, + pub(super) picture_looks: std::collections::HashMap, + 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, + pub(super) password: Option, + #[cfg(not(target_arch = "wasm32"))] + pub(super) recovery_slot: crate::recovery::Slot, + pub(super) also_selected: Vec, + pub(super) column: Option, + pub(super) column_mode: bool, + pub(super) widened: Option, + pub(super) edit_marks: Vec, + 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, + /// 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 { + 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> { + 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 { + 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(&mut self, id: u64, f: impl FnOnce(&mut Session) -> R) -> Result { + 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 { + 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) { + 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}")) +} diff --git a/crates/engine/src/session/views.rs b/crates/engine/src/session/views.rs new file mode 100644 index 0000000000..48c991eae3 --- /dev/null +++ b/crates/engine/src/session/views.rs @@ -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, + goal_x: Option, + page_hint: usize, + hf_return: Option, + typing_open: bool, + join_next: bool, + equation_join_next: bool, + ime_text_join: Option, + typing: Option, + math: Option, + also_selected: Vec, + column: Option, + column_mode: bool, + widened: Option, + /// 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(); + } +} diff --git a/crates/engine/src/speech.rs b/crates/engine/src/speech.rs index 74a6b165d6..215f9304ee 100644 --- a/crates/engine/src/speech.rs +++ b/crates/engine/src/speech.rs @@ -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, } 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, } } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0bff645718..f13ebe26a2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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#"

Pic

"#).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"))); diff --git a/crates/engine/src/tests_documents.rs b/crates/engine/src/tests_documents.rs new file mode 100644 index 0000000000..36ecb471f2 --- /dev/null +++ b/crates/engine/src/tests_documents.rs @@ -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 { + 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![("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![(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); +} diff --git a/crates/fonts/Cargo.toml b/crates/fonts/Cargo.toml index 9107da44f1..8c0322a7d6 100644 --- a/crates/fonts/Cargo.toml +++ b/crates/fonts/Cargo.toml @@ -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 } diff --git a/crates/fonts/src/color.rs b/crates/fonts/src/color.rs index 897f31c96b..3e0a35641d 100644 --- a/crates/fonts/src/color.rs +++ b/crates/fonts/src/color.rs @@ -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::()), + ColorOp::Fill(ColorPaint::Linear { stops, .. } | ColorPaint::Radial { stops, .. } | ColorPaint::Sweep { stops, .. }) => { + stops.len().saturating_mul(std::mem::size_of::()) + } + _ => 0, + }; + ops.iter().fold(ops.len().saturating_mul(std::mem::size_of::()), |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> { palette: &'a [[u8; 4]], outline: F, ops: Vec, + payload: usize, overflow: bool, } -impl<'a, F: Fn(GlyphId) -> BezPath> Recorder<'a, F> { +impl<'a, F: Fn(GlyphId, usize) -> Option> 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) -> Point { Point::new(p.x as f64, p.y as f64) } -impl BezPath> ColorPainter for Recorder<'_, F> { +impl Option> 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 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) { 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 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 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 { + 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::()); + } + + #[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, diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 8402846909..9e8472bc30 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -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>, // Some legacy CJK fonts assemble their contours with TrueType instructions. outline_hinter: std::sync::OnceLock>, + /// 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 { + 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 { let east_asian = east_asian_font(f); let axes = f.axes(); let mut named: Vec = 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 { }] } -/// 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 { +/// 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 { 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 = 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 = (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 { + 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 = 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 { + 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 { + 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::>(); + 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::>(), [(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()); + } } diff --git a/crates/fonts/src/fontdb/user.rs b/crates/fonts/src/fontdb/user.rs index bc7ce6a6dd..f7cd0fd918 100644 --- a/crates/fonts/src/fontdb/user.rs +++ b/crates/fonts/src/fontdb/user.rs @@ -149,8 +149,24 @@ pub fn decode_font_file(bytes: &[u8]) -> Result, 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, 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, Box> { + 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> { + 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 { + 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 { + 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, wuff::WuffErr>) -> Result, 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 { + // 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::(); + 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 { + 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::(); 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 { + 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 { + 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 { + 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> { + static LOG: std::sync::OnceLock>> = 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::>() + .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); + } } diff --git a/crates/fonts/src/tests_sysfonts.rs b/crates/fonts/src/tests_sysfonts.rs index 5d9c965df8..5a0912c4b2 100644 --- a/crates/fonts/src/tests_sysfonts.rs +++ b/crates/fonts/src/tests_sysfonts.rs @@ -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. diff --git a/crates/formats/Cargo.toml b/crates/formats/Cargo.toml index bf447f2752..faf6e41583 100644 --- a/crates/formats/Cargo.toml +++ b/crates/formats/Cargo.toml @@ -25,4 +25,5 @@ chrono = { workspace = true } js-sys = "0.3" [dev-dependencies] +wordcraft-doc = { workspace = true, features = ["test-support"] } proptest = { workspace = true } diff --git a/crates/formats/src/html.rs b/crates/formats/src/html.rs index fa6ec57997..a3d76578c4 100644 --- a/crates/formats/src/html.rs +++ b/crates/formats/src/html.rs @@ -724,7 +724,7 @@ fn apply_style(css: &str, f: &mut Fmt, align: &mut Option, 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 `
` was trimmed one + // span at a time with `Vec::remove(0)`. + let html = format!("

{}x

", "

".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 = f.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p.text()) } else { None }).collect(); + assert_eq!(texts, ["x"]); + } } diff --git a/crates/formats/src/latex.rs b/crates/formats/src/latex.rs index 10de892120..d2c35f3cbb 100644 --- a/crates/formats/src/latex.rs +++ b/crates/formats/src/latex.rs @@ -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::().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::().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"); + } } diff --git a/crates/formats/src/lib.rs b/crates/formats/src/lib.rs index 6678b5c954..8af0d2a503 100644 --- a/crates/formats/src/lib.rs +++ b/crates/formats/src/lib.rs @@ -91,5 +91,7 @@ pub fn export_with(ext: &str, doc: &Document, opts: &ExportOptions) -> Option 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; +/// A destination is shared by every reference to it, not copied into each (#3713). +pub type Refs = HashMap>; /// 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) -> 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 { 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 { 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 { 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) -> Vec { } 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) -> Vec { 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 = Vec::new(); - let mut link: Option = None; + let mut link: Option> = None; let close_all = |out: &mut String, stack: &mut Vec| { 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\\* & "); 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 = 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)"); + for l in [ + "javab 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]()"); + assert_eq!(md("https://e.com/x\ny"), "[x](https://e.com/xy)"); + } } diff --git a/crates/formats/src/model.rs b/crates/formats/src/model.rs index ef050223ea..ccc1f1c0cb 100644 --- a/crates/formats/src/model.rs +++ b/crates/formats/src/model.rs @@ -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> { + 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> { + 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 = 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, + /// Shared: every span of a link holds the same target. + pub link: Option>, pub color: Option, /// Points. pub size: Option, - pub font: Option, + /// Shared, as `link`. + pub font: Option>, /// Background (highlight / shading). pub background: Option, /// 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) -> 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)]); + } } diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs index c73720819b..472507f94f 100644 --- a/crates/formats/src/odt.rs +++ b/crates/formats/src/odt.rs @@ -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, background: Option, size: Option, - font: Option, + font: Option>, code: Option, } @@ -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 { - 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, Opti /// Parse an `.odt` package into a document. pub fn import(bytes: &[u8]) -> Result { + model::begin_import(); let (flow, last, track, font, parts) = parse_tracked(bytes)?; let mut doc = model::to_doc(&flow); if font.is_some() { diff --git a/crates/formats/src/rtf.rs b/crates/formats/src/rtf.rs index 280b2feae2..2a9503fbd6 100644 --- a/crates/formats/src/rtf.rs +++ b/crates/formats/src/rtf.rs @@ -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| s.map(|s| (s * 2.0).round().clamp(2.0, 3276.0) as i32); @@ -1106,7 +1109,7 @@ struct Reader { st: St, stack: Vec, overflow: usize, - fonts: HashMap, + fonts: HashMap>, 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 { 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], rights: &[Vec]) -> Vec { /// Parse RTF bytes into a document. pub fn import(bytes: &[u8]) -> Result { + 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. diff --git a/crates/formats/src/sheet.rs b/crates/formats/src/sheet.rs index 520fca2f80..f7f300812e 100644 --- a/crates/formats/src/sheet.rs +++ b/crates/formats/src/sheet.rs @@ -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, Vec Vec { let mut out: Vec = 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 = HashSet::with_capacity(row.len()); + let mut next: HashMap = 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>, 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, 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) -> Result<(Vec, Vec, 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, u64, u64, Option)> = None; let mut cell: Option = 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) -> Result<(Vec, Vec { 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) -> Result<(Vec, Vec 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::().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. + 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 = ["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 { + let content = format!( + r#"{rows}"# + ); + 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 `` + // 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#""# + )); + 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#"next"# + )); + 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#""# + ); + let book = ods(&format!("{over}{over}{over}next")); + 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("fine"); + 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#""#, + 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::(), MAX_CELLS); + } } diff --git a/crates/formats/src/sheet/xls.rs b/crates/formats/src/sheet/xls.rs index 3a70f5308a..9d8dcfb123 100644 --- a/crates/formats/src/sheet/xls.rs +++ b/crates/formats/src/sheet/xls.rs @@ -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, Ve } fn workbook_stream(bytes: &[u8]) -> Result, 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 = (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(&[ diff --git a/crates/formats/src/test_log.rs b/crates/formats/src/test_log.rs new file mode 100644 index 0000000000..745ce21fe1 --- /dev/null +++ b/crates/formats/src/test_log.rs @@ -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> = 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(f: impl FnOnce() -> T) -> (T, Vec) { + // 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()))) +} diff --git a/crates/formats/src/tests.rs b/crates/formats/src/tests.rs index ca6b6f9926..bc2442d80a 100644 --- a/crates/formats/src/tests.rs +++ b/crates/formats/src/tests.rs @@ -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)> = - 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)> = 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::(); @@ -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 = p.inlines.iter().filter_map(|i| if let model::Inline::Text(_, f) = i { f.link.clone() } else { None }).collect(); + let mut urls: Vec = + 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(" 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::() >= 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!("

kept too

"); + 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::>()); + + 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!("{}", "aa".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 = 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::(), 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!( + "

a b c

" + ); + 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("

fine

")); + 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:?}"); +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 48998abd32..868ccf025d 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -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; diff --git a/crates/geom/src/pixels.rs b/crates/geom/src/pixels.rs index 64258a71f8..263cadfce2 100644 --- a/crates/geom/src/pixels.rs +++ b/crates/geom/src/pixels.rs @@ -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)); diff --git a/crates/layout/src/balloons.rs b/crates/layout/src/balloons.rs index ec20b0db03..d20c7987a6 100644 --- a/crates/layout/src/balloons.rs +++ b/crates/layout/src/balloons.rs @@ -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> { 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 = 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; diff --git a/crates/layout/src/contour.rs b/crates/layout/src/contour.rs index d58d0e0c8d..e185aed1fe 100644 --- a/crates/layout/src/contour.rs +++ b/crates/layout/src/contour.rs @@ -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> { - 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, +} + +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>>, + /// 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>, 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, 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> { + 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> 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> mod tests { use super::*; + fn picture_outline(bytes: &[u8], crop: [f32; 4]) -> Option> { + 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 } diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index f7f4d510a6..f537790f96 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -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 { 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 = + 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 { + 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); 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) { +fn body_change_bars(doc: &Document, page: &Page, items: &[Placed], opts: &DisplayOptions, alpha: f32, out: &mut Vec) { 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], opts: &DisplayOptions, a: f32, out: &mut Vec) { +/// `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, +) { 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 = 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], 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], 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, 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, (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, Rect)], story: StoryRef, path: &[u32]) -> Option { - 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 { + 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, ) { 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, 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) { +fn change_bars(doc: &Document, page: &Page, items: &[Placed], m: &MarkupOptions, alpha: f32, out: &mut Vec) { // 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) { - item_in(doc, it, opts, alpha, Spin::default(), out) +fn item(doc: &Document, it: &Placed, opts: &DisplayOptions, authors: &Authors, alpha: f32, out: &mut Vec) { + 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) { +fn item_in(doc: &Document, it: &Placed, opts: &DisplayOptions, authors: &Authors, alpha: f32, outer: Spin, out: &mut Vec) { 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, @@ -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) -> 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>, +} + +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) -> Vec { } 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, 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 { + 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 = 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::>(); + 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()); + }); + } +} diff --git a/crates/layout/src/hit.rs b/crates/layout/src/hit.rs index 8a4840479c..c0f5f20bc4 100644 --- a/crates/layout/src/hit.rs +++ b/crates/layout/src/hit.rs @@ -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 { }) } +/// 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>>, + /// 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>, +} + +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 { + 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 + '_> { + let items = story_items(page, story); + #[cfg(test)] + let items: Box + '_> = Box::new(items.inspect(|_| crate::work::steps(1))); + items +} + +fn story_items(page: &Page, story: StoryRef) -> Box + '_> { 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, + 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 = (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) { diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 23f855c5e4..6771be76b6 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -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, /// 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>>>, + /// 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, /// 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>, + /// 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(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>, Option>>)>; + +/// 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>, + crop: [f32; 4], +) -> Option>> { + 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>> { - 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| defining_width(§ions, si, |s| s.headers, i); - let fb = |i: Option| defining_width(§ions, si, |s| s.footers, i); + let (heads, feet) = (hf.heads.set(si), hf.feet.set(si)); + let hb = |i: Option| hf.defining_width(false, si, i); + let fb = |i: Option| 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, 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) -> Option { - 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) -> Option { + 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, + linked: Vec, + /// The sections naming each part, in order. + namers: HashMap>, +} + +impl Linked { + fn new(sections: &[(usize, &SectionProps)], get: fn(&SectionProps) -> HeaderSet) -> Self { + let own: Vec = sections.iter().map(|(_, s)| get(s)).collect(); + let mut linked = Vec::with_capacity(own.len()); + let mut namers: HashMap> = 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(ctx: &mut Ctx, base: Option, 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 { } 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 { 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 = (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 { 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 { 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 = 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 { - let set = linked(sections, page.section, get); + let pick = |linked: &Linked| -> Option { + 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; diff --git a/crates/layout/src/math.rs b/crates/layout/src/math.rs index 7291e89dd6..9f1874ad7e 100644 --- a/crates/layout/src/math.rs +++ b/crates/layout/src/math.rs @@ -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); + } } diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 11e8f95c83..abbecc13ae 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -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 { 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)> { + 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 { - 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 { + 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}'); diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index d31967af22..08e50361cc 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -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 = 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> { 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 { 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)))) diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 7ed459c39d..6c63582ea3 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -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, diff --git a/crates/layout/src/tests_budget.rs b/crates/layout/src/tests_budget.rs new file mode 100644 index 0000000000..483ec44976 --- /dev/null +++ b/crates/layout/src/tests_budget.rs @@ -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 = (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 = + 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 { + 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 = (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 = (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 = (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 = 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 { + 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 { + 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"); +} diff --git a/crates/layout/src/tests_linear.rs b/crates/layout/src/tests_linear.rs new file mode 100644 index 0000000000..4dc2d741dc --- /dev/null +++ b/crates/layout/src/tests_linear.rs @@ -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(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::>(), [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 { + 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}"); +} diff --git a/crates/layout/src/tests_nbsp.rs b/crates/layout/src/tests_nbsp.rs index b8dbc9aad7..0f00c5b994 100644 --- a/crates/layout/src/tests_nbsp.rs +++ b/crates/layout/src/tests_nbsp.rs @@ -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 { diff --git a/crates/layout/src/tests_table_contact.rs b/crates/layout/src/tests_table_contact.rs index 8f3800c961..65a86f0f5f 100644 --- a/crates/layout/src/tests_table_contact.rs +++ b/crates/layout/src/tests_table_contact.rs @@ -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}; diff --git a/crates/layout/src/textbox.rs b/crates/layout/src/textbox.rs index c3b87f7c77..22ace35d11 100644 --- a/crates/layout/src/textbox.rs +++ b/crates/layout/src/textbox.rs @@ -208,6 +208,21 @@ struct Cut { byte: usize, } +/// Most line lengths (and text directions) a chain's story is laid out at. Each lays the whole +/// story out again and is kept while the chain is split, so a chain of boxes that each differ in +/// width would multiply its story's layout work and memory. Past 16, a box whose length isn't +/// among them takes the nearest narrower one laid out in its text direction, so its lines still +/// fit across it (they stop short of its far edge), else the narrowest, whose lines are longer +/// than the box. Far above a real chain's few box sizes. +pub(crate) const MAX_FLOW_WIDTHS: usize = 16; + +/// Line lengths are told apart to a twentieth of a point (a twip): resized boxes a hair apart +/// share one layout. Stacked text is laid out at [`STACK_WIDTH`] whatever the box's size. +fn flow_key(len: f32, vert: TextVert) -> (i32, TextVert) { + let len = if vert == TextVert::Stacked { STACK_WIDTH } else { len }; + ((len * 20.0).round() as i32, vert) +} + /// Split the text of `chain`'s first box over its boxes. fn flow(ctx: &mut Ctx, chain: &[u32], depth: usize) -> Vec> { let Some(&head) = chain.first() else { return Vec::new() }; @@ -221,7 +236,7 @@ fn flow(ctx: &mut Ctx, chain: &[u32], depth: usize) -> Vec> { // time), sharing a layout with the boxes before it of the same length. let snap = (ctx.counters.clone(), ctx.eq_count); let mut after = None; - let mut layouts: Vec<((u32, TextVert), Vec)> = Vec::new(); + let mut layouts: Vec<((i32, TextVert), Vec)> = Vec::new(); let mut out = Vec::with_capacity(chain.len()); // Where the next box's text starts: `Some(None)` at the beginning, `None` when all is shown. let mut start: Option> = Some(None); @@ -233,8 +248,13 @@ fn flow(ctx: &mut Ctx, chain: &[u32], depth: usize) -> Vec> { let body = ctx.doc.parts.get(id).map(|p| p.body).unwrap_or_default(); let (w, h) = sizes.get(id).copied().unwrap_or((0.0, 0.0)); let (len, across) = frame(&body, w, h); - let key = (len.to_bits(), body.vert); - let items = match layouts.iter().find(|(k, _)| *k == key) { + let key = flow_key(len, body.vert); + // Past the cap the box takes the nearest layout in its direction: the widest line length + // that fits it, else the narrowest. The first box in a direction is laid out (one more + // layout for each of the few directions at most). + let near = || layouts.iter().filter(|((_, v), _)| *v == key.1).min_by_key(|((k, _), _)| (*k > key.0, k.abs_diff(key.0))); + let kept = layouts.iter().find(|(k, _)| *k == key).or_else(|| if layouts.len() >= MAX_FLOW_WIDTHS { near() } else { None }); + let items = match kept { Some((_, items)) => items.clone(), None => { (ctx.counters, ctx.eq_count) = (snap.0.clone(), snap.1); diff --git a/crates/layout/src/work.rs b/crates/layout/src/work.rs new file mode 100644 index 0000000000..b36368e162 --- /dev/null +++ b/crates/layout/src/work.rs @@ -0,0 +1,29 @@ +//! A count of the steps the layout's lookups take, for the linear-time tests (#3692): they check +//! that an input 8x the size costs about 8x the steps, which a busy machine can't skew the way it +//! skews a timing. Each walk that once ran again for every item it served (a line's clusters, a +//! page's items, the revision authors, the sections, a table's rows) counts the elements it +//! looks at, so a caller that walks it once per item again shows up as a quadratic count. Outside +//! tests the count is nothing. + +#[cfg(test)] +thread_local! { + /// The steps counted on this thread (layout runs on the thread that calls it). + static STEPS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Count `n` steps of a walk (nothing outside tests). +#[inline(always)] +pub(crate) fn steps(n: usize) { + #[cfg(test)] + STEPS.with(|s| s.set(s.get().saturating_add(n as u64))); + #[cfg(not(test))] + let _ = n; +} + +/// The steps `f` takes. +#[cfg(test)] +pub(crate) fn count(f: impl FnOnce()) -> u64 { + let before = STEPS.with(std::cell::Cell::get); + f(); + STEPS.with(std::cell::Cell::get).saturating_sub(before) +} diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index a07c893913..3de7878c7b 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -1,7 +1,7 @@ //! Where MCP tool calls end up: a control-channel method call. use std::io::{BufReader, Write}; -use std::net::{IpAddr, SocketAddr, TcpStream, ToSocketAddrs}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream, ToSocketAddrs}; use std::path::Path; use std::time::Duration; @@ -33,7 +33,7 @@ pub struct Remote { next_id: u64, /// Whether a connection ever succeeded (`lazy` starts without one; the app may open later). connected: bool, - /// The connection leads to a loopback address: requests carry the control key. + /// The connection leads to the app's 127.0.0.1: requests carry the control key. send_key: bool, /// The control key, looked up once per connection (the app picks a new one at every start). key: Option, @@ -62,25 +62,46 @@ fn find_key(addr: &str) -> Result { lookup_key(std::env::var("WORDCRAFT_CONTROL_KEY").ok(), wordcraft_control_key::settings_dir().as_deref(), port) } -/// Whether `addr` (`HOST:PORT`, as given to `--connect`) names a loopback address: 127.0.0.0/8, -/// ::1 or `localhost`. Other host names are not resolved: they never count. -fn is_loopback_addr(addr: &str) -> bool { - let loopback = |ip: IpAddr| ip.to_canonical().is_loopback(); - if let Ok(sa) = addr.parse::() { - return loopback(sa.ip()); - } - if let Ok(ip) = addr.parse::() { - return loopback(ip); - } +/// The only address the app's control server binds (`apps/wordcraft/src/control_server.rs`). +const APP_IP: IpAddr = IpAddr::V4(Ipv4Addr::LOCALHOST); + +/// The host part of `addr` (`HOST:PORT`, `[V6]:PORT`), without brackets. +fn host_of(addr: &str) -> &str { let host = addr.rsplit_once(':').map_or(addr, |(h, _)| h); - let host = host.strip_prefix('[').and_then(|h| h.strip_suffix(']')).unwrap_or(host); - host.parse::().map_or_else(|_| host.eq_ignore_ascii_case("localhost"), loopback) + host.strip_prefix('[').and_then(|h| h.strip_suffix(']')).unwrap_or(host) } -/// The key goes only to a loopback `--connect` address, and only when the connection really -/// leads to one (`peer`, so a `localhost` that resolves elsewhere gets no key either). +/// Whether `addr` (`HOST:PORT`, as given to `--connect`) names the app's own address: +/// 127.0.0.1 or `localhost`. Other loopback addresses (127.0.0.2, ::1) don't count: the app never +/// listens there, so whatever does is not the app. Other host names are not resolved. +fn is_app_addr(addr: &str) -> bool { + let app = |ip: IpAddr| ip.to_canonical() == APP_IP; + if let Ok(sa) = addr.parse::() { + return app(sa.ip()); + } + if let Ok(ip) = addr.parse::() { + return app(ip); + } + let host = host_of(addr); + host.parse::().map_or_else(|_| host.eq_ignore_ascii_case("localhost"), app) +} + +/// The key goes only to the app's `--connect` address, and only when the connection really +/// leads to 127.0.0.1 (`peer`): a listener on another loopback address (::1, 127.0.0.2), which +/// any local user can open, never sees it. fn may_send_key(addr: &str, peer: Option) -> bool { - is_loopback_addr(addr) && peer.is_some_and(|p| p.ip().to_canonical().is_loopback()) + is_app_addr(addr) && peer.is_some_and(|p| p.ip().to_canonical() == APP_IP) +} + +/// Where to connect for `addr`: `localhost` means the app's 127.0.0.1 (not whatever the resolver +/// lists first, which may be ::1); anything else as resolved. +fn targets(addr: &str) -> std::io::Result> { + if host_of(addr).eq_ignore_ascii_case("localhost") + && let Some(port) = addr.rsplit_once(':').and_then(|(_, p)| p.parse::().ok()) + { + return Ok(vec![SocketAddr::new(APP_IP, port)]); + } + Ok(addr.to_socket_addrs()?.collect()) } impl Remote { @@ -104,11 +125,13 @@ impl Remote { Self { addr: addr.to_string(), conn: None, next_id: 1, connected: false, send_key: false, key: None, find_key: Box::new(find_key) } } + /// The address the app is reached at, as given. pub fn addr(&self) -> &str { &self.addr } - /// Why requests go without the control key (the address is not loopback), if they do. + /// Why requests go without the control key (the address is not the app's 127.0.0.1), if they + /// do. pub fn key_warning(&self) -> Option { // Nothing is known about the address until a connection succeeds. if !self.connected { @@ -116,17 +139,19 @@ impl Remote { } (!self.send_key).then(|| { format!( - "{} is not a loopback address, so the control key is not sent (it only goes to 127.0.0.0/8, ::1 and localhost) and the app refuses requests; reach the app through 127.0.0.1, for example with an SSH tunnel", + "{} is not the app's address, so the control key is not sent (it only goes to 127.0.0.1 and localhost, where the app listens) and the app refuses requests; reach the app through 127.0.0.1, for example with an SSH tunnel", self.addr ) }) } + /// Drop the connection and connect again to the first of the address's targets that answers, + /// deciding afresh whether the control key may go to it. fn reconnect(&mut self) -> std::io::Result<()> { self.conn = None; self.key = None; let mut last = std::io::Error::new(std::io::ErrorKind::NotFound, format!("cannot resolve {}", self.addr)); - for sa in self.addr.to_socket_addrs()? { + for sa in targets(&self.addr)? { match TcpStream::connect_timeout(&sa, Duration::from_millis(800)) { Ok(s) => { s.set_nodelay(true).ok(); @@ -287,15 +312,12 @@ mod tests { } #[test] - fn key_only_for_loopback_addresses() { + fn key_only_for_the_apps_address() { let peer = |s: &str| s.parse::().ok(); for (addr, at) in [ ("127.0.0.1:7981", "127.0.0.1:7981"), - ("127.0.0.2:7981", "127.0.0.2:7981"), - ("[::1]:7981", "[::1]:7981"), ("[::ffff:127.0.0.1]:7981", "[::ffff:127.0.0.1]:7981"), ("localhost:7981", "127.0.0.1:7981"), - ("localhost:7981", "[::1]:7981"), ("LocalHost:7981", "127.0.0.1:7981"), ] { assert!(may_send_key(addr, peer(at)), "{addr} at {at}"); @@ -305,6 +327,11 @@ mod tests { ("10.0.0.7:7981", "10.0.0.7:7981"), ("[fe80::1]:7981", "[fe80::1]:7981"), ("0.0.0.0:7981", "127.0.0.1:7981"), + // Loopback addresses the app never binds: another local user's listener (#3699). + ("127.0.0.2:7981", "127.0.0.2:7981"), + ("[::1]:7981", "[::1]:7981"), + ("localhost:7981", "[::1]:7981"), + ("127.0.0.1:7981", "127.0.0.2:7981"), // Host names other than localhost: even when they lead to this machine. ("workstation:7981", "127.0.1.1:7981"), ("example.org:7981", "93.184.215.14:7981"), @@ -318,6 +345,16 @@ mod tests { assert!(!may_send_key("127.0.0.1:7981", None)); } + #[test] + fn localhost_connects_to_the_apps_address_only() { + // Not the resolver's list, which may put ::1 (where anyone can listen) first. + let at: SocketAddr = "127.0.0.1:7981".parse().unwrap(); + assert_eq!(targets("localhost:7981").unwrap(), vec![at]); + assert_eq!(targets("LOCALHOST:7981").unwrap(), vec![at]); + assert_eq!(targets("127.0.0.1:7981").unwrap(), vec![at]); + assert_eq!(targets("[::1]:7981").unwrap(), vec!["[::1]:7981".parse::().unwrap()]); + } + /// A stand-in for the app on 127.0.0.1: every request line goes to the returned channel and /// gets `answer(request)`: a reply and whether to close the connection after it, or `None` /// to close it without a reply (the app quit). @@ -456,9 +493,9 @@ mod tests { let finder: KeyFinder = Box::new(|_| Err("the key must not be read for another host".to_string())); let mut r = Remote::connect_with(&format!("127.0.0.1:{port}"), finder).unwrap(); r.send_key = false; - assert!(r.key_warning().is_some_and(|w| w.contains("not a loopback address"))); + assert!(r.key_warning().is_some_and(|w| w.contains("not the app's address"))); let e = r.call("document.inspect", json!({})).unwrap_err(); - assert!(e.contains("not a loopback address") && e.contains("not sent"), "{e}"); + assert!(e.contains("not the app's address") && e.contains("not sent"), "{e}"); assert!(seen.recv_timeout(WAIT).unwrap().get("key").is_none()); } } diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index 9c4a875bfe..d96e77411f 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -35,30 +35,59 @@ impl Headless { self } - fn answer(&mut self, method: &str, params: Value) -> Result { - match method { - "engine.execute" | "command" => { - let id = params.get("command").and_then(Value::as_str).ok_or("missing `command`")?.to_string(); - let p = params.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({})); - self.session.run(&id, &p).map_err(|e| e.to_string()) - } - "engine.commands" => Ok(self.session.registry.describe()), - "document.inspect" => self.session.run("document.inspect", ¶ms).map_err(|e| e.to_string()), - "ui.parity" => Ok(wordcraft_engine::catalog::parity(&self.session.registry)), - "ui.render" | "render.page" => { - let page = params.get("page").and_then(Value::as_u64).unwrap_or(1).max(1) as usize - 1; - let scale = params.get("scale").and_then(Value::as_f64).unwrap_or(1.0).clamp(0.1, 4.0) as f32; - let l = self.session.layout(); - let pg = l.pages.get(page).ok_or_else(|| format!("no page {}", page + 1))?; - let img = wordcraft_render::render_page(&self.session.doc, pg, scale, &Default::default()); - let png = img.to_png(); - Ok( - json!({"png": wordcraft_engine::cmd::insert::base64_encode(&png), "width": img.width, "height": img.height, "pages": l.pages.len()}), - ) - } - m if m.starts_with("ui.") => Err(format!("`{m}` {NEEDS_APP}")), - other => self.session.run(other, ¶ms).map_err(|e| e.to_string()), + /// Answer `method`; a document method addressed to another open document (`doc`) runs on + /// that document, and the active one stays active. + fn answer(&mut self, method: &str, mut params: Value) -> Result { + if !DOC_METHODS.contains(&method) { + return answer_on(&mut self.session, method, params); } + let doc = doc_param(¶ms)?; + if let Some(o) = params.as_object_mut() { + o.remove("doc"); + } + match doc { + None => answer_on(&mut self.session, method, params), + Some(id) => self.session.with_document(id, |s| answer_on(s, method, params)).map_err(|e| e.to_string())?, + } + } +} + +/// The methods that work on one document, and so take `doc`: the id of the open document to work +/// on (`view.switchWindows` lists them), the active one when absent. +pub const DOC_METHODS: &[&str] = &["engine.execute", "command", "document.inspect", "ui.render", "render.page"]; + +/// The document a request addresses with `doc`: `None` (absent or null) is the active document. +/// Anything but a whole number from 0 up is an error; whether it names an open document is for +/// the session to say. +pub fn doc_param(params: &Value) -> Result, String> { + match params.get("doc") { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_u64().map(Some).ok_or_else(|| "`doc` must be the id of an open document (list_documents lists them)".to_string()), + } +} + +fn answer_on(session: &mut Session, method: &str, params: Value) -> Result { + match method { + "engine.execute" | "command" => { + let id = params.get("command").and_then(Value::as_str).ok_or("missing `command`")?.to_string(); + let p = params.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({})); + session.run(&id, &p).map_err(|e| e.to_string()) + } + "engine.commands" => Ok(session.registry.describe()), + "document.inspect" => session.run("document.inspect", ¶ms).map_err(|e| e.to_string()), + "ui.parity" => Ok(wordcraft_engine::catalog::parity(&session.registry)), + "ui.render" | "render.page" => { + let page = params.get("page").and_then(Value::as_u64).unwrap_or(1).max(1); + let page = usize::try_from(page).unwrap_or(usize::MAX).saturating_sub(1); + let scale = params.get("scale").and_then(Value::as_f64).unwrap_or(1.0).clamp(0.1, 4.0) as f32; + let l = session.layout(); + let pg = l.pages.get(page).ok_or_else(|| format!("no page {}", page.saturating_add(1)))?; + let img = wordcraft_render::render_page(&session.doc, pg, scale, &Default::default()); + let png = img.to_png(); + Ok(json!({"png": wordcraft_engine::cmd::insert::base64_encode(&png), "width": img.width, "height": img.height, "pages": l.pages.len()})) + } + m if m.starts_with("ui.") => Err(format!("`{m}` {NEEDS_APP}")), + other => session.run(other, ¶ms).map_err(|e| e.to_string()), } } diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index 0bca29e643..b3eaf5b49a 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -22,7 +22,10 @@ const INSTRUCTIONS: &str = "WordCraft is a word processor (a Microsoft Word clon parameters with list_commands and run them with execute (or several with batch). Positions are \ {story:\"body\", path:[block,(row,cell,block)...], off:byteOffset}. Typical flow: new_document → type_text \ \"Hello\" → execute text.newParagraph → execute para.style {style:\"Heading 1\"} → select_text {text:\"Hello\"} → \ -execute format.bold → inspect_document to verify → render_page to look at the result → save {path:\"out.docx\"}."; +execute format.bold → inspect_document to verify → render_page to look at the result → save {path:\"out.docx\"}. \ +Several documents can be open: new_document and open_document open another one and make it active; \ +list_documents gives their ids, and `doc` on execute, batch and the other document tools works on one \ +without switching."; /// Resource URIs. pub const DOC_URI: &str = "wordcraft://document"; diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index f23f72f751..87d74bcdb7 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -101,21 +101,22 @@ fn type_text_paragraphs_is_one_step_and_makes_the_same_paragraphs() { } } - // The lines are typed as they are: Enter's AutoCorrect (a capital for the next sentence, a - // fixed typo) doesn't run on the breaks. The per-line way capitalised "two" (and not the last - // line, which no Enter followed), so what was written depended on the line's place. + // Each break is the Enter it stands for: Enter's AutoCorrect (a capital for a sentence's + // first word, a fixed typo) runs on the word the break ends, so "one" is capitalised and + // "teh" is fixed; the last line, which no Enter follows, is left as typed (#3692). let mut s = Server::new(Box::new(Headless::default())); tool(&mut s, "new_document", json!({})); tool(&mut s, "type_text", json!({"text": "one\ntwo teh\nthree", "paragraphs": true})); let texts: Vec = paragraphs(&mut s).into_iter().map(|p| p.0).collect(); - assert_eq!(texts, ["one", "two teh", "three"]); + assert_eq!(texts, ["One", "two the", "three"]); // CRLF is one break per line end. let mut s = Server::new(Box::new(Headless::default())); tool(&mut s, "new_document", json!({})); tool(&mut s, "type_text", json!({"text": "a\r\nb\r\nc", "paragraphs": true})); let texts: Vec = paragraphs(&mut s).into_iter().map(|p| p.0).collect(); - assert_eq!(texts, ["a", "b", "c"]); + // A CRLF is one break, and each break capitalises the word it ends (Enter's AutoCorrect). + assert_eq!(texts, ["A", "B", "c"]); // One Undo step takes all the lines back. let mut s = Server::new(Box::new(Headless::default())); @@ -191,17 +192,16 @@ fn headless_sessions_keep_the_dictionary_in_memory() { assert!(h.session.user_dict_path.is_none()); } -/// A backend that refuses every text insertion (a protected document, say). -struct RefusesInsert { - calls: std::sync::Arc>>, +/// A backend that keeps every command it is sent and refuses typed paragraphs. +struct RefusesParagraph { + calls: std::sync::Arc>>, } -impl crate::backend::Backend for RefusesInsert { +impl crate::backend::Backend for RefusesParagraph { fn call(&mut self, _method: &str, params: Value) -> Result { - let command = params["command"].as_str().unwrap_or("").to_string(); - self.calls.lock().unwrap().push((command.clone(), params["params"].clone())); - match command.as_str() { - "text.insert" => Err("editing refused".into()), + self.calls.lock().unwrap().push(params.clone()); + match params["params"]["paragraphs"].as_bool() { + Some(true) => Err("paragraph break refused".into()), _ => Ok(json!({"ok": true})), } } @@ -209,24 +209,92 @@ impl crate::backend::Backend for RefusesInsert { false } fn describe(&self) -> String { - "refuses edits".into() + "refuses paragraphs".into() } } -/// #1356: a refused edit is reported and nothing is typed after it. The lines are one -/// `text.insert` now (#3973), so a protected document refuses all of them at once, never a -/// line at a time with the next one typed after the break that failed. +/// #1356: a refused paragraph break is reported, and nothing of the text goes in without it: the +/// lines are one command, all or nothing. #[test] fn type_text_paragraphs_reports_a_refusal_and_types_nothing_after_it() { let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let mut s = Server::new(Box::new(RefusesInsert { calls: calls.clone() })); + let mut s = Server::new(Box::new(RefusesParagraph { calls: calls.clone() })); let r = call(&mut s, 11, "tools/call", json!({"name": "type_text", "arguments": {"text": "one\ntwo", "paragraphs": true}})); assert_eq!(r["result"]["isError"], true, "{r}"); - assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("editing refused"), "{r}"); + assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("paragraph break refused"), "{r}"); let calls = calls.lock().unwrap(); - assert_eq!(calls.len(), 1, "one command for all the lines: {calls:?}"); - assert_eq!(calls[0].0, "text.insert"); - assert_eq!(calls[0].1["text"], "one\rtwo", "the line break is a paragraph break"); + assert_eq!(calls.len(), 1, "{calls:?}"); + assert_eq!(calls[0]["command"], "text.insert"); + assert_eq!(calls[0]["params"]["text"], "one\ntwo"); + assert_eq!(calls[0]["params"]["paragraphs"], true); +} + +/// #3692: typed paragraphs are one edit and one undo step, not a command (and a whole-document +/// undo snapshot) per line; too many lines are refused by the engine before anything is typed. +#[test] +fn type_text_paragraphs_are_one_undo_step_with_a_line_cap() { + let mut s = Server::new(Box::new(Headless::default())); + tool(&mut s, "new_document", json!({})); + let lines: Vec = (0..2000).map(|i| format!("Line {i}")).collect(); + tool(&mut s, "type_text", json!({"text": lines.join("\r\n"), "paragraphs": true})); + let text = tool(&mut s, "get_text", json!({}))["text"].as_str().unwrap().to_string(); + assert!(text.contains("Line 0") && text.contains("Line 1999"), "{}", text.len()); + let paras = |s: &mut Server| tool(s, "inspect_document", json!({}))["blocks"].as_array().unwrap().len(); + assert_eq!(paras(&mut s), 2000); + tool(&mut s, "execute", json!({"command": "edit.undo"})); + assert_eq!(paras(&mut s), 1, "one Undo takes all the lines back out"); + assert!(!tool(&mut s, "get_text", json!({}))["text"].as_str().unwrap().contains("Line")); + + // One line over the engine's cap ("x\n" repeated gives a last, empty line): refused, nothing typed. + let many = "x\n".repeat(wordcraft_engine::cmd::text::MAX_TYPED_PARAGRAPHS); + let r = call(&mut s, 12, "tools/call", json!({"name": "type_text", "arguments": {"text": many, "paragraphs": true}})); + assert_eq!(r["result"]["isError"], true, "{r}"); + assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("at most 10000 paragraphs"), "{r}"); + assert_eq!(paras(&mut s), 1, "nothing typed"); + assert!(!tool(&mut s, "get_text", json!({}))["text"].as_str().unwrap().contains('x')); +} + +/// Ultrareview of #3692: each typed paragraph break is Enter's own (text.newParagraph's +/// implementation), so a one-line content control refuses it, and all of the text with it. +#[test] +fn type_text_paragraphs_are_refused_in_a_one_line_control() { + let mut s = Server::new(Box::new(Headless::default())); + tool(&mut s, "new_document", json!({})); + tool(&mut s, "type_text", json!({"text": "Name: "})); + tool(&mut s, "execute", json!({"command": "developer.plainText", "params": {"tag": "name"}})); + tool(&mut s, "execute", json!({"command": "caret.right"})); + let before = tool(&mut s, "get_text", json!({}))["text"].clone(); + let r = call(&mut s, 13, "tools/call", json!({"name": "type_text", "arguments": {"text": "Ada\nLovelace", "paragraphs": true}})); + assert_eq!(r["result"]["isError"], true, "{r}"); + assert_eq!(tool(&mut s, "get_text", json!({}))["text"], before, "nothing typed"); + assert_eq!(tool(&mut s, "inspect_document", json!({}))["blocks"].as_array().unwrap().len(), 1); +} + +/// Ultrareview of #3692: Enter's AutoFormat runs on each typed paragraph break ("---" becomes a +/// border, "+---+---+" a table), and the lines are still one undo step however many of them +/// AutoFormat changes. +#[test] +fn type_text_paragraphs_autoformat_like_enter_in_one_undo_step() { + let mut s = Server::new(Box::new(Headless::default())); + tool(&mut s, "new_document", json!({})); + tool(&mut s, "type_text", json!({"text": "Above\n---\nBelow\n+---+---+\nCell", "paragraphs": true})); + let blocks = tool(&mut s, "inspect_document", json!({}))["blocks"].clone(); + let blocks = blocks.as_array().unwrap(); + assert_eq!(blocks[0]["text"], "Above", "{blocks:?}"); + assert!(blocks[0]["props"]["borders"]["bottom"].is_object(), "--- is a border: {blocks:?}"); + assert_eq!(blocks[1]["text"], "Below", "{blocks:?}"); + assert_eq!(blocks[2]["type"], "table", "+---+---+ is a table: {blocks:?}"); + assert!(blocks[2].to_string().contains("Cell"), "typing goes on in its first cell: {blocks:?}"); + tool(&mut s, "execute", json!({"command": "edit.undo"})); + assert_eq!(tool(&mut s, "inspect_document", json!({}))["blocks"].as_array().unwrap().len(), 1, "one Undo"); + assert_eq!(tool(&mut s, "get_text", json!({}))["text"].as_str().unwrap().trim(), ""); + + // Many AutoFormatted lines: still one undo step (no snapshot per line). + let lines: Vec<&str> = (0..500).flat_map(|_| ["Text", "==="]).collect(); + tool(&mut s, "type_text", json!({"text": lines.join("\n"), "paragraphs": true})); + assert_eq!(tool(&mut s, "inspect_document", json!({}))["blocks"].as_array().unwrap().len(), 501); + tool(&mut s, "execute", json!({"command": "edit.undo"})); + assert_eq!(tool(&mut s, "inspect_document", json!({}))["blocks"].as_array().unwrap().len(), 1, "one Undo"); } /// #3685: an over-long request line gets an error reply and the server keeps serving. @@ -245,3 +313,45 @@ fn over_long_request_line_is_rejected_and_serving_continues() { assert!(lines[0].contains("too long"), "{}", lines[0]); assert!(lines[1].contains(r#""id":5"#), "{}", lines[1]); } + +/// W1: with several documents open, `doc` sends a tool to one of them; the active document stays +/// active and unchanged, and an id that names no open document is an error. +#[test] +fn tools_address_an_open_document_by_id() { + let mut s = Server::new(Box::new(Headless::default())); + let first = tool(&mut s, "new_document", json!({}))["id"].as_u64().expect("new_document gives the id"); + tool(&mut s, "type_text", json!({"text": "First"})); + let second = tool(&mut s, "new_document", json!({}))["id"].as_u64().expect("new_document gives the id"); + assert_ne!(first, second); + tool(&mut s, "type_text", json!({"text": "Second"})); + + tool(&mut s, "execute", json!({"command": "text.insert", "params": {"text": " more"}, "doc": first})); + tool(&mut s, "batch", json!({"commands": [{"command": "text.insert", "params": {"text": " again"}}], "doc": first})); + assert_eq!(tool(&mut s, "get_text", json!({"doc": first}))["text"].as_str().unwrap().trim(), "First more again"); + assert_eq!(tool(&mut s, "get_text", json!({}))["text"].as_str().unwrap().trim(), "Second"); + let inspected = tool(&mut s, "inspect_document", json!({"doc": first})); + assert_eq!(inspected["blocks"][0]["text"], "First more again"); + let r = call(&mut s, 12, "tools/call", json!({"name": "render_page", "arguments": {"doc": first, "scale": 0.2}})); + assert_eq!(r["result"]["content"][0]["type"], "image", "{r}"); + + let list = tool(&mut s, "list_documents", json!({})); + let docs = list["documents"].as_array().unwrap(); + let ids: Vec = docs.iter().filter_map(|d| d["id"].as_u64()).collect(); + assert!(ids.contains(&first) && ids.contains(&second), "{list}"); + let active: Vec = docs.iter().filter(|d| d["active"] == true).filter_map(|d| d["id"].as_u64()).collect(); + assert_eq!(active, vec![second]); + + for bad in [json!(u64::MAX), json!(-1), json!("1"), json!(first.to_string()), json!(1.5), json!(1e300), json!({}), json!([first])] { + for (name, args) in [ + ("execute", json!({"command": "text.insert", "params": {"text": "x"}, "doc": bad})), + ("get_text", json!({"doc": bad})), + ("inspect_document", json!({"doc": bad})), + ("render_page", json!({"doc": bad})), + ] { + let r = call(&mut s, 13, "tools/call", json!({"name": name, "arguments": args})); + assert_eq!(r["result"]["isError"], true, "{name} with doc {bad}: {r}"); + } + } + assert_eq!(tool(&mut s, "get_text", json!({}))["text"].as_str().unwrap().trim(), "Second"); + assert_eq!(tool(&mut s, "get_text", json!({"doc": first}))["text"].as_str().unwrap().trim(), "First more again"); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index f543043e49..26fcafa97b 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -48,10 +48,21 @@ fn n(desc: &str) -> Value { fn obj(props: Value, req: &[&str]) -> Value { json!({"type": "object", "properties": props, "required": req}) } +/// Adds the optional `doc` property to a tool's schema. +fn with_doc(mut schema: Value) -> Value { + if let Some(p) = schema.get_mut("properties").and_then(Value::as_object_mut) { + p.insert("doc".into(), json!({"type": "integer", "minimum": 0, "description": DOC_DESC})); + } + schema +} + +const DOC_DESC: &str = "Id of the open document to work on (list_documents); the active document when left out. The active document stays active."; + fn tool(name: &str, title: &str, desc: &str, schema: Value, ro: bool) -> Value { json!({"name": name, "title": title, "description": desc, "inputSchema": schema, "annotations": {"title": title, "readOnlyHint": ro, "openWorldHint": false}}) } +/// Every MCP tool with its title, description, input schema and annotations (`tools/list`). pub fn tool_definitions() -> Value { json!([ tool( @@ -64,31 +75,31 @@ pub fn tool_definitions() -> Value { tool( "execute", "Run a command", - "Run one command by id with params, e.g. {command:\"format.bold\"} or {command:\"insert.table\", params:{rows:3, cols:4}}.", - obj(json!({"command": s("Command id"), "params": {"type": "object", "description": "Command parameters"}}), &["command"]), + "Run one command by id with params, e.g. {command:\"format.bold\"} or {command:\"insert.table\", params:{rows:3, cols:4}}. Commands work on the active document, or on the open document `doc` names. file.new and file.open open another document beside the open ones and make it active; view.switchWindows {id} makes another one active and file.closeDocument {id, force?} closes one.", + with_doc(obj(json!({"command": s("Command id"), "params": {"type": "object", "description": "Command parameters"}}), &["command"])), false ), tool( "batch", "Run several commands", - "Run commands in order; stops at the first error unless keepGoing.", - obj( + "Run commands in order; stops at the first error unless keepGoing. `doc` sends every command to that open document.", + with_doc(obj( json!({"commands": {"type": "array", "items": {"type": "object", "properties": {"command": {"type": "string"}, "params": {"type": "object"}}, "required": ["command"]}}, "keepGoing": {"type": "boolean"}}), &["commands"] - ), + )), false ), tool( "new_document", "New document", - "Start a document: blank, sample, letter, resume or report.", + "Start a document: blank, sample, letter, resume or report. It opens beside the documents already open and becomes the active one; the reply's `id` addresses it (`doc`).", obj(json!({"template": s("blank|sample|letter|resume|report")}), &[]), false ), tool( "open_document", "Open", - "Open a .docx, .doc, .odt, .rtf, .md, .tex, .html, .txt or .json file, or any other text file (.m3u, .log, .ini…) as plain text. Give `password` for a protected file; without it the call fails asking for one.", + "Open a .docx, .doc, .odt, .rtf, .md, .tex, .html, .txt or .json file, or any other text file (.m3u, .log, .ini…) as plain text. Give `password` for a protected file; without it the call fails asking for one. The file opens beside the documents already open (it replaces a blank one nobody has used) and becomes the active one; a file that is already open becomes active again (`alreadyOpen`). The reply's `id` addresses it (`doc`).", obj(json!({"path": s("File path"), "password": s("Password, if the file is protected")}), &["path"]), false ), @@ -96,36 +107,46 @@ pub fn tool_definitions() -> Value { "save_document", "Save", "Save (format from the extension: docx, pdf, odt, rtf, html, md, tex, txt, png).", - obj(json!({"path": s("File path; omit to save in place")}), &[]), + with_doc(obj(json!({"path": s("File path; omit to save in place")}), &[])), false ), tool( "type_text", "Type text", - "Type at the caret (replacing the selection). Use \\n... for line breaks inside text; use execute text.newParagraph for new paragraphs, or `paragraphs: true` to split lines into paragraphs (all in one step, one Undo; the line breaks are plain text breaks: no AutoCorrect or AutoFormat runs on them).", - obj(json!({"text": s("Text"), "paragraphs": {"type": "boolean"}}), &["text"]), + &format!( + "Type at the caret (replacing the selection). Use \\n... for line breaks inside text; use execute text.newParagraph for new paragraphs, or `paragraphs: true` to split lines into paragraphs (one undo step, at most {} lines).", + wordcraft_engine::cmd::text::MAX_TYPED_PARAGRAPHS + ), + with_doc(obj(json!({"text": s("Text"), "paragraphs": {"type": "boolean"}}), &["text"])), false ), tool( "select_text", "Select text", "Select the n-th occurrence of some text.", - obj(json!({"text": s("Text to find"), "occurrence": n("1-based")}), &["text"]), + with_doc(obj(json!({"text": s("Text to find"), "occurrence": n("1-based")}), &["text"])), false ), - tool("get_text", "Document text", "Body text with tracked deletions omitted.", obj(json!({}), &[]), true), + tool("get_text", "Document text", "Body text with tracked deletions omitted.", with_doc(obj(json!({}), &[])), true), tool( "inspect_document", "Inspect document", "Structure: blocks with text, styles, formatting runs, lists, tables, sections, parts, selection, pages.", - obj(json!({}), &[]), + with_doc(obj(json!({}), &[])), true ), tool( "render_page", "Render page", "Render a page to PNG to look at the result.", - obj(json!({"page": n("1-based page"), "scale": n("Pixels per point (default 1)")}), &[]), + with_doc(obj(json!({"page": n("1-based page"), "scale": n("Pixels per point (default 1)")}), &[])), + true + ), + tool( + "list_documents", + "Open documents", + "The open documents in the order they were opened: id, title, path, dirty (unsaved changes) and active (the one commands work on by default). new_document and open_document open another document beside these. Give a document's id as `doc` to execute, batch, type_text, select_text, get_text, inspect_document, render_page or save_document to work on it while it stays in the background; execute view.switchWindows {id} makes it the active one, file.closeDocument {id, force?} closes it.", + obj(json!({}), &[]), true ), tool("parity", "Feature parity", "WordCraft's command coverage of the word-processor feature catalog.", obj(json!({}), &[]), true), @@ -154,8 +175,31 @@ pub fn tool_definitions() -> Value { ]) } -fn exec(b: &mut dyn Backend, id: &str, params: Value) -> Result { - b.call("engine.execute", json!({"command": id, "params": params})) +fn exec(b: &mut dyn Backend, id: &str, params: Value, doc: Option<&Value>) -> Result { + b.call("engine.execute", addressed(json!({"command": id, "params": params}), doc)) +} + +/// `params` with the tool's `doc`, when it gave one. The backend checks it: a session says +/// whether it names an open document. +fn addressed(mut params: Value, doc: Option<&Value>) -> Value { + if let (Some(d), Some(o)) = (doc, params.as_object_mut()) { + o.insert("doc".into(), d.clone()); + } + params +} + +/// The active document's id, added to a reply that opened or made one. +fn with_active_id(b: &mut dyn Backend, r: Result) -> Result { + let mut v = r?; + if v.get("id").is_none() + && let Ok(list) = exec(b, "view.switchWindows", json!({}), None) + && let Some(id) = + list.get("documents").and_then(Value::as_array).and_then(|d| d.iter().find(|d| d["active"] == true)).and_then(|d| d.get("id")) + && let Some(o) = v.as_object_mut() + { + o.insert("id".into(), id.clone()); + } + Ok(v) } fn wrap(r: Result) -> ToolResult { @@ -165,8 +209,11 @@ fn wrap(r: Result) -> ToolResult { } } +/// Run tool `name` with arguments `a` through backend `b` (`tools/call`); unknown tools and +/// failed calls are error results, never panics. pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { let st = |k: &str| a.get(k).and_then(Value::as_str); + let doc = a.get("doc").filter(|d| !d.is_null()); match name { "list_commands" => { let q = st("query").unwrap_or("").to_lowercase(); @@ -188,7 +235,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { } } "execute" => match st("command") { - Some(c) => wrap(exec(b, c, a.get("params").cloned().unwrap_or(json!({})))), + Some(c) => wrap(exec(b, c, a.get("params").cloned().unwrap_or(json!({})), doc)), None => ToolResult::error("missing `command`"), }, "batch" => { @@ -196,7 +243,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { let mut out = Vec::new(); for c in a.get("commands").and_then(Value::as_array).cloned().unwrap_or_default() { let id = c.get("command").and_then(Value::as_str).unwrap_or(""); - let r = exec(b, id, c.get("params").cloned().unwrap_or(json!({}))); + let r = exec(b, id, c.get("params").cloned().unwrap_or(json!({})), doc); let failed = r.is_err(); out.push(match r { Ok(v) => json!({"command": id, "ok": true, "result": v}), @@ -208,13 +255,17 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { } ToolResult::json(&Value::Array(out)) } - "new_document" => wrap(exec(b, "file.new", json!({"template": st("template").unwrap_or("blank")}))), - "open_document" => wrap(exec(b, "file.open", json!({"path": st("path").unwrap_or(""), "password": st("password")}))), + "new_document" => { + let r = exec(b, "file.new", json!({"template": st("template").unwrap_or("blank")}), None); + wrap(with_active_id(b, r)) + } + "open_document" => wrap(exec(b, "file.open", json!({"path": st("path").unwrap_or(""), "password": st("password")}), None)), + "list_documents" => wrap(exec(b, "view.switchWindows", json!({}), None)), "save_document" => { let p = st("path"); let ext = p.and_then(|x| x.rsplit('.').next()).unwrap_or("").to_ascii_lowercase(); if ext == "png" { - wrap(exec(b, "file.exportPng", json!({"path": p}))) + wrap(exec(b, "file.exportPng", json!({"path": p}), doc)) } else { wrap(exec( b, @@ -223,31 +274,37 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { Some(p) => json!({"path": p}), None => json!({}), }, + doc, )) } } "type_text" => { let t = st("text").unwrap_or(""); if a.get("paragraphs").and_then(Value::as_bool).unwrap_or(false) { - // One command for all the lines: `text.insert` takes a carriage return as a - // paragraph break. A command per line was quadratic in the document (each one - // snapshots all of it for Undo: 10,000 lines took 2.8 s, 7 ms in one command), - // and left an Undo step per line. CRLF is one break, not two (#3973). - wrap(exec(b, "text.insert", json!({"text": t.replace("\r\n", "\n").replace('\n', "\r"), "raw": true}))) + // One `text.insert` with `paragraphs`: each break is Enter's own (its AutoFormat, + // and its refusal in a one-line control), but the lines go in as one edit and one + // undo step, all or nothing, instead of a command (and a whole-document undo + // snapshot) per line. The engine splits the lines and holds them to its cap + // (`MAX_TYPED_PARAGRAPHS`), for this tool and every other caller alike. + wrap(exec(b, "text.insert", json!({"text": t, "raw": true, "paragraphs": true}), doc)) } else { - wrap(exec(b, "text.insert", json!({"text": t, "raw": true}))) + wrap(exec(b, "text.insert", json!({"text": t, "raw": true}), doc)) } } - "select_text" => { - wrap(exec(b, "select.text", json!({"text": st("text").unwrap_or(""), "occurrence": a.get("occurrence").cloned().unwrap_or(json!(1))}))) - } - "get_text" => wrap(exec(b, "document.text", json!({}))), - "inspect_document" => wrap(b.call("document.inspect", json!({}))), + "select_text" => wrap(exec( + b, + "select.text", + json!({"text": st("text").unwrap_or(""), "occurrence": a.get("occurrence").cloned().unwrap_or(json!(1))}), + doc, + )), + "get_text" => wrap(exec(b, "document.text", json!({}), doc)), + "inspect_document" => wrap(b.call("document.inspect", addressed(json!({}), doc))), "parity" => wrap(b.call("ui.parity", json!({}))), // Pictures travel inside the reply (`png`), headless or through the app: no file with a // predictable name in the shared temporary folder. "render_page" => { - let params = json!({"page": a.get("page").cloned().unwrap_or(json!(1)), "scale": a.get("scale").cloned().unwrap_or(json!(1.0))}); + let params = + addressed(json!({"page": a.get("page").cloned().unwrap_or(json!(1)), "scale": a.get("scale").cloned().unwrap_or(json!(1.0))}), doc); match b.call("ui.render", params) { Ok(v) => png_result(&v, &json!({"width": v["width"], "height": v["height"], "pages": v["pages"]})), Err(e) => ToolResult::error(e), @@ -312,6 +369,21 @@ mod tests { assert_eq!(forwarded(json!({"path": "/tmp/a.docx"}))["password"], Value::Null); } + /// `doc` travels beside the command, not inside its params, so the app's control channel can + /// run the command on that document; without one nothing is added. + #[test] + fn doc_is_forwarded_beside_the_command() { + let mut b = Recording { seen: None }; + call_tool(&mut b, "execute", &json!({"command": "text.insert", "params": {"text": "x"}, "doc": 7})); + let (_, call) = b.seen.take().expect("the backend was called"); + assert_eq!(call["doc"], json!(7)); + assert!(call["params"].get("doc").is_none(), "{call}"); + call_tool(&mut b, "inspect_document", &json!({"doc": 7})); + assert_eq!(b.seen.take().expect("called").1["doc"], json!(7)); + call_tool(&mut b, "execute", &json!({"command": "text.insert", "params": {"text": "x"}})); + assert!(b.seen.take().expect("called").1.get("doc").is_none()); + } + /// The password has to be in the schema, or a client has no way to know it can be sent. #[test] fn the_schema_advertises_the_password_as_optional() { diff --git a/crates/mcp/tests/automation_roots.rs b/crates/mcp/tests/automation_roots.rs index 415ab99d81..7b618ac499 100644 --- a/crates/mcp/tests/automation_roots.rs +++ b/crates/mcp/tests/automation_roots.rs @@ -208,3 +208,31 @@ fn links_out_of_a_root_are_refused() { refused(&mut server, "save_document", json!({"path": s(&base.join("out/away/doc.docx"))}), "outside the write root"); assert!(!base.join("outside/doc.docx").exists()); } + +/// The names in `dir`, sorted. +fn names(dir: &Path) -> Vec { + let mut names: Vec = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + names.sort(); + names +} + +#[cfg(unix)] +#[test] +fn a_link_outside_the_write_root_into_it_is_not_written() { + // #3735: the link's target is inside the write root, but a save replaces the link itself, + // and its temporary file would sit beside it, outside. + let (mut server, base) = confined("link-in", true, true); + std::fs::write(base.join("out/doc.docx"), "inside").unwrap(); + std::os::unix::fs::symlink(base.join("out/doc.docx"), base.join("outside/alias.docx")).unwrap(); + let before = names(&base.join("outside")); + ok(&mut server, "type_text", json!({"text": "Hello"})); + refused(&mut server, "save_document", json!({"path": s(&base.join("outside/alias.docx"))}), "outside the write root"); + let (error, text) = run(&mut server, "file.exportPdf", json!({"path": s(&base.join("outside/alias.docx"))})); + assert!(error && text.contains("outside the write root"), "{text}"); + assert_eq!(names(&base.join("outside")), before, "no temporary file, nothing replaced"); + assert!(std::fs::symlink_metadata(base.join("outside/alias.docx")).unwrap().file_type().is_symlink()); + assert_eq!(std::fs::read_to_string(base.join("out/doc.docx")).unwrap(), "inside"); + // Saving to the real file inside still works. + ok(&mut server, "save_document", json!({"path": s(&base.join("out/doc.docx"))})); + assert_ne!(std::fs::read(base.join("out/doc.docx")).unwrap(), b"inside"); +} diff --git a/crates/metafile/src/canvas.rs b/crates/metafile/src/canvas.rs index 03a8f34532..bb6ecd3167 100644 --- a/crates/metafile/src/canvas.rs +++ b/crates/metafile/src/canvas.rs @@ -284,9 +284,12 @@ impl Canvas { fn push(&mut self, raw: Raw) { let cost = match &raw { - Raw::Path { ops, .. } => ops.len() * std::mem::size_of::(), - Raw::Bitmap { img, .. } => img.px.len(), - Raw::Text(t) => t.text.len() + t.font.len() + t.dx.len() * std::mem::size_of::(), + // What is held, capacity and all. + Raw::Path { ops, .. } => ops.capacity().saturating_mul(std::mem::size_of::()), + Raw::Bitmap { img, .. } => img.px.capacity(), + Raw::Text(t) => { + t.text.capacity().saturating_add(t.font.capacity()).saturating_add(t.dx.capacity().saturating_mul(std::mem::size_of::())) + } }; if self.items.len() >= MAX_ITEMS || self.used.saturating_add(cost) > MAX_BYTES { self.full = true; diff --git a/crates/metafile/src/geom.rs b/crates/metafile/src/geom.rs index 0547de18ba..0c2b772cf1 100644 --- a/crates/metafile/src/geom.rs +++ b/crates/metafile/src/geom.rs @@ -99,7 +99,17 @@ fn arc(cx: f64, cy: f64, rx: f64, ry: f64, a0: f64, sweep: f64) -> Vec { /// Consecutive subpaths: `counts[i]` points each, taken in order from `pts`. Counts that run past the /// point list are cut short rather than failing the whole record. pub(crate) fn subpaths(pts: &[P], counts: &[usize], close: bool) -> Vec { - let mut ops = Vec::with_capacity(pts.len() + counts.len()); + // The exact size first: empty groups add no ops, so a long count list of zeros (file data) + // mustn't reserve room the canvas budget never sees. + let (mut n, mut at) = (0usize, 0usize); + for &c in counts { + let end = at.saturating_add(c).min(pts.len()); + if end > at { + n = n.saturating_add(end - at + usize::from(close)); + } + at = end; + } + let mut ops = Vec::with_capacity(n); let mut at = 0usize; for &c in counts { let end = at.saturating_add(c).min(pts.len()); diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index eaa5b16740..a71481ffaa 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -1053,3 +1053,18 @@ fn plain_wmf_target_comes_from_a_window_that_was_read() { let (x0, y0, x1, y1) = rect_extent(&p.items[0]); assert!(close(x0, 0.0) && close(y0, 0.0) && close(x1, 200.0) && close(y1, 100.0), "{x0} {y0} {x1} {y1}"); } + +/// A poly-polygon's group counts are file data: a long list of empty groups adds no ops, so it +/// mustn't reserve room for them either (the canvas charges what a path holds, capacity included). +#[test] +fn empty_poly_groups_reserve_nothing() { + let pts = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]; + let mut counts = vec![0usize; 100_000]; + counts.push(3); + let ops = crate::geom::subpaths(&pts, &counts, true); + assert_eq!(ops.len(), 4, "M L L Z"); + assert!(ops.capacity() <= 8, "capacity {}", ops.capacity()); + // Counts past the points are cut short, and the size still matches. + let ops = crate::geom::subpaths(&pts, &[2, 5, 4], false); + assert_eq!((ops.len(), ops.capacity()), (3, 3)); +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 8fa8b236de..f0e65c4d49 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -47,7 +47,7 @@ use wordcraft_doc::{Document, Path as DocPath, Rgb, StoryRef}; use wordcraft_fonts::FaceRef; use wordcraft_geom::Rect; use wordcraft_layout::display::Paint; -use wordcraft_layout::display::{DisplayOptions, Draw, Stroke as LineStyle, page_display}; +use wordcraft_layout::display::{DisplayOptions, Draw, ItemDisplay, Stroke as LineStyle}; use wordcraft_layout::para::{ClKind, LineEnd}; use wordcraft_layout::{DocLayout, LayoutCache, LayoutOptions, NumeralMode, Page, Placed, layout}; use wordcraft_metafile::{Picture, PlacedItem}; @@ -747,7 +747,9 @@ struct Exporter<'a> { /// Bookmark positions by name (the first of a name wins), gathered once: looking each internal /// link up by walking the whole document made a long document with a linked table of contents /// take seconds to export. - bookmarks: std::cell::OnceCell>, + /// Bookmarks by exact name, and by ASCII-lowercased name for the case-blind fallback; the + /// first in document order wins either way. + bookmarks: std::cell::OnceCell<(HashMap, HashMap)>, } /// What the content being drawn is, for tagging. @@ -918,8 +920,10 @@ impl Exporter<'_> { placeholders: false, ..Default::default() }; + // What the items share (the authors' colours, the clipped cells) is worked out once per page. + let shown = ItemDisplay::new(self.doc, page); for it in page.header.iter().chain(page.footer.iter()) { - let draws = self.draws(page, it, &dopts); + let draws = shown.draws(it, &dopts); self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| { for d in &draws { me.draw(s, d); @@ -927,7 +931,7 @@ impl Exporter<'_> { }); } for it in &page.items { - let draws = self.draws(page, it, &dopts); + let draws = shown.draws(it, &dopts); match it { Placed::Lines { story, path, para, .. } => { let idx = self.entry(*story, path, para); @@ -989,19 +993,6 @@ impl Exporter<'_> { } } - /// Display items for one placed item. - fn draws(&self, page: &Page, it: &Placed, dopts: &DisplayOptions) -> Vec { - let mut items = vec![it.clone()]; - // Lines in a cell cut off at its edge are drawn inside its clip: `page_display` finds the - // cell among the items (cells themselves draw nothing). - if matches!(it, Placed::Lines { .. }) { - let clipped = page.header.iter().chain(&page.footer).chain(&page.items).filter(|c| matches!(c, Placed::Cell { clip: true, .. })); - items.extend(clipped.cloned()); - } - let one = Page { w: page.w, h: page.h, section: page.section, number: page.number, items, body: page.body, ..Default::default() }; - page_display(self.doc, &one, dopts) - } - /// A heading's text for bookmarks and tags: what the pages show. That leaves out what its /// layout `pl` left out (hidden text, resolved as laid out, so table formatting counts too) and, /// without markup, tracked deletions. @@ -1544,14 +1535,15 @@ impl Exporter<'_> { /// Where a bookmark is: (output page, x, y) — the page and line its start lands on, which /// in a paragraph split across pages need not be the paragraph's first page. fn bookmark(&self, name: &str) -> Option<(usize, f32, f32)> { - let marks = self.bookmarks.get_or_init(|| { - let mut m = HashMap::new(); + let (exact, folded) = self.bookmarks.get_or_init(|| { + let (mut exact, mut folded) = (HashMap::new(), HashMap::new()); for (n, pos) in self.doc.bookmarks() { - m.entry(n).or_insert(pos); + folded.entry(n.to_ascii_lowercase()).or_insert_with(|| pos.clone()); + exact.entry(n).or_insert(pos); } - m + (exact, folded) }); - let pos = marks.get(name).or_else(|| marks.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v))?.clone(); + let pos = exact.get(name).or_else(|| folded.get(&name.to_ascii_lowercase()))?.clone(); if let Some(c) = self.lay.caret(&pos) && let Some(out) = self.out_index.get(&c.page) { @@ -1703,11 +1695,12 @@ impl Exporter<'_> { match font { Some(font) => { let upem = face.upem.max(1.0) as f32; + let ends = cluster_ends(&shaped.iter().map(|g| g.cluster).collect::>(), t.text.len()); let kg: Vec = shaped .iter() .enumerate() .map(|(i, g)| { - let end = shaped.iter().skip(i + 1).map(|n| n.cluster).find(|cl| *cl > g.cluster).unwrap_or(t.text.len()); + let end = ends.get(i).copied().unwrap_or(t.text.len()); let start = g.cluster.min(end); let x = lay.glyphs.get(i).map_or(0.0, |p| p.0); let adv = match lay.glyphs.get(i + 1) { @@ -1758,11 +1751,12 @@ impl Exporter<'_> { if font.is_none() && !self.outlined.contains(&face.id()) { return; } + let ends = cluster_ends(&shaped.iter().map(|g| g.cluster).collect::>(), text.len()); let glyphs: Vec = shaped .iter() .enumerate() .map(|(i, g)| { - let end = shaped.iter().skip(i + 1).map(|n| n.cluster).find(|cl| *cl > g.cluster).unwrap_or(text.len()); + let end = ends.get(i).copied().unwrap_or(text.len()); let start = g.cluster.min(end); KrillaGlyph::new( GlyphId::new(g.gid), @@ -1891,6 +1885,23 @@ fn path_from(b: &kurbo::BezPath) -> Option { pb.finish() } +/// Where each glyph's text ends: the cluster (byte offset) of the first later glyph whose cluster +/// is past the glyph's own, else `len`. One backward pass keeping the later clusters that can +/// still be an answer, so a long run of glyphs sharing a cluster (combining marks) stays linear. +fn cluster_ends(clusters: &[usize], len: usize) -> Vec { + let mut ends = vec![len; clusters.len()]; + // Later clusters, nearest last, each greater than every one nearer. + let mut later: Vec = Vec::new(); + for (end, &c) in ends.iter_mut().zip(clusters).rev() { + while later.last().is_some_and(|&n| n <= c) { + later.pop(); + } + *end = later.last().copied().unwrap_or(len); + later.push(c); + } + ends +} + /// Decode a picture for embedding (validated with the `image` crate first, so a broken file /// can't fail the whole export). fn load_image(data: &Arc>) -> Option { @@ -1903,19 +1914,28 @@ fn load_image(data: &Arc>) -> Option { /// [`load_image`] with an explicit RGBA byte budget (a small one in tests). fn load_image_within(data: &Arc>, budget: u64) -> Option { + use image::ImageDecoder; let fmt = image::guess_format(data).ok()?; - // Header-only size check before anything is allocated (decode buffer + RGBA conversion are - // charged); the side limits and the default 512 MB allocation cap stay as a second guard. - let (hw, hh) = image::ImageReader::new(std::io::Cursor::new(data.as_slice())).with_guessed_format().ok()?.into_dimensions().ok()?; - if !wordcraft_geom::decode_fits(hw, hh, budget) { - return None; - } - let mut reader = image::ImageReader::new(std::io::Cursor::new(data.as_slice())).with_guessed_format().ok()?; let mut limits = image::Limits::default(); limits.max_image_width = Some(MAX_IMAGE_SIDE); limits.max_image_height = Some(MAX_IMAGE_SIDE); - reader.limits(limits); - let dec = reader.decode().ok()?; + let reader = || { + let mut r = image::ImageReader::new(std::io::Cursor::new(data.as_slice())).with_guessed_format().ok()?; + r.limits(limits.clone()); + Some(r) + }; + // Header-only size check before anything is allocated (the decoder's buffer at its own pixel + // size, 8 bytes for 16-bit RGBA, plus the RGBA conversion are charged); the side limits and + // the default 512 MB allocation cap apply to the header read and the decode alike. + let (hw, hh, native) = { + let d = reader()?.into_decoder().ok()?; + let (w, h) = d.dimensions(); + (w, h, u64::from(d.color_type().bytes_per_pixel())) + }; + if !wordcraft_geom::decode_peak_fits(hw, hh, native, budget) { + return None; + } + let dec = reader()?.decode().ok()?; let (w, h) = (dec.width(), dec.height()); if w == 0 || h == 0 || w > MAX_IMAGE_SIDE || h > MAX_IMAGE_SIDE { return None; diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 2d572d7ea2..f6548d9c79 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -1323,6 +1323,7 @@ fn bookmark_destination_is_the_page_of_its_start() { let mut p = Paragraph::with_text("Paragraph start\u{c}Bookmark target", CharProps::default()); let at = "Paragraph start\u{c}".len(); p.insert_object(at, InlineObject::BookmarkStart { name: "target".into() }, &CharProps::default()).unwrap(); + p.insert_object(at, InlineObject::BookmarkStart { name: "First".into() }, &CharProps::default()).unwrap(); p.insert_object(0, InlineObject::BookmarkStart { name: "first".into() }, &CharProps::default()).unwrap(); d.body = vec![para_block(p)]; let lay = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); @@ -1354,6 +1355,13 @@ fn bookmark_destination_is_the_page_of_its_start() { let (page_ci, x_ci, y_ci) = ex.bookmark("TARGET").unwrap(); assert_eq!(page_ci, 1); assert_eq!((x_ci, y_ci), (x, y)); + // An exact name wins; names that differ only in case fall back to the first in the document + // (a folded index, not a scan of a hash map in arbitrary order for every link). + assert_eq!(ex.bookmark("First").map(|b| b.0), Some(1)); + assert_eq!(ex.bookmark("FIRST").map(|b| b.0), Some(0)); + assert!(ex.bookmark("absent").is_none()); + let (exact, folded) = ex.bookmarks.get().unwrap(); + assert_eq!((exact.len(), folded.len()), (3, 2)); // Whitespace in ISO date string is parsed successfully assert!(parse_iso(" 2024-05-06T07:08:09Z \n").is_some()); @@ -2031,7 +2039,7 @@ fn bundled_unicode_glyphs_are_visible_and_extractable() { let lay = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); let mut drawn = String::new(); for page in &lay.pages { - for draw in page_display(&d, page, &DisplayOptions::default()) { + for draw in wordcraft_layout::display::page_display(&d, page, &DisplayOptions::default()) { if let Draw::Glyphs { face, glyphs, text, .. } = draw { assert_eq!(face.data(), bundled.data(), "page glyphs must use the bundled face without system fallback"); assert!(glyphs.iter().all(|(id, _, _)| *id != 0), "page glyphs must not use .notdef"); @@ -2140,6 +2148,13 @@ fn image_budget_is_checked_from_the_header() { // The decoded image and its RGBA conversion are charged: two buffers at peak. assert!(load_image_within(&data, 1024).is_some()); assert!(load_image_within(&data, 1023).is_none()); + // 16-bit RGBA decodes to 8 bytes a pixel, alive with its RGBA8 conversion: 12 bytes a pixel. + let mut wide = Vec::new(); + let img16 = image::ImageBuffer::, _>::from_fn(16, 8, |x, y| image::Rgba([x as u16 * 999, y as u16, 9, 65535])); + image::DynamicImage::ImageRgba16(img16).write_to(&mut std::io::Cursor::new(&mut wide), image::ImageFormat::Png).unwrap(); + let wide = Arc::new(wide); + assert!(load_image_within(&wide, 1024).is_none(), "two RGBA8 buffers' worth is not enough for RGBA16"); + assert!(load_image_within(&wide, 1536).is_some()); // A header (valid CRC) claiming 30000x30000 with no pixel data reads fine, then the budget refuses it. let hdr = ihdr_only_png(30_000, 30_000); let dims = image::ImageReader::new(std::io::Cursor::new(hdr.as_slice())).with_guessed_format().unwrap().into_dimensions().unwrap(); @@ -2411,3 +2426,27 @@ fn only_safe_link_targets_become_annotations() { .collect(); assert_eq!(uris, ["https://example.com/", "mailto:ada@example.com"]); } + +/// A glyph's text runs to the first later glyph with a cluster past its own (the end of the text +/// without one), found in one pass: a long run of glyphs sharing a cluster used to rescan the rest +/// of the run for every glyph. +#[test] +fn cluster_ends_match_the_next_larger_cluster_in_linear_time() { + let brute = |cl: &[usize], len: usize| -> Vec { + (0..cl.len()).map(|i| cl.iter().skip(i + 1).copied().find(|n| *n > cl[i]).unwrap_or(len)).collect::>() + }; + for cl in [vec![], vec![0], vec![0, 0, 3, 3, 5], vec![5, 3, 3, 0], vec![2, 0, 4, 1, 1, 6, 3], vec![0, 7, 2, 2, 9, 1]] { + assert_eq!(cluster_ends(&cl, 10), brute(&cl, 10), "{cl:?}"); + } + // One base letter and a million combining marks in its cluster, then one more letter. + // Milliseconds; the rescan (`brute`) took 2.9 s at 50,000 and grows with the square, so + // about 20 minutes here. + let n = 1_000_000; + let mut cl = vec![0; n + 1]; + cl.push(2 * n + 2); + let start = std::time::Instant::now(); + let ends = cluster_ends(&cl, 2 * n + 4); + assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed()); + assert!(ends[..=n].iter().all(|e| *e == 2 * n + 2)); + assert_eq!(ends.last(), Some(&(2 * n + 4))); +} diff --git a/crates/proof/Cargo.toml b/crates/proof/Cargo.toml index 234684a9fe..4c1a5b1121 100644 --- a/crates/proof/Cargo.toml +++ b/crates/proof/Cargo.toml @@ -10,7 +10,7 @@ workspace = true [dependencies] log = { workspace = true } -miniz_oxide = "0.8" +miniz_oxide = "0.9" unicode-normalization = "0.1" [dev-dependencies] diff --git a/crates/proof/src/affix.rs b/crates/proof/src/affix.rs index c2580bca77..03fc27a6bd 100644 --- a/crates/proof/src/affix.rs +++ b/crates/proof/src/affix.rs @@ -120,6 +120,11 @@ fn parse_cond(text: &str) -> Result, String> { const MAX_CHAIN: u8 = 2; /// How many suffixes may sit under the outermost one: the depth `form_has_flag` walks. const INNER_CHAIN: u8 = MAX_CHAIN - 1; +/// Most forms [`AffixDict::forms`] lists for one stem (real stems have a few hundred at most). +pub const MAX_FORMS: usize = 4096; +/// Most rules [`AffixDict::forms`] tries along continuation chains and prefix–suffix cross +/// products for one stem: real dictionaries need a few thousand, hostile ones are cut off. +const MAX_FORMS_WORK: usize = 1 << 18; /// One prefix or suffix rule. #[derive(Clone, Debug)] @@ -182,6 +187,8 @@ pub struct AffixDict { suffixes: Vec, /// Appended string → groups of suffix rules by stripped string. sfx_index: FxMap, Vec>, + /// Per flag bit: the suffix rules of that flag, in rule order. + sfx_by_flag: Vec>, /// Longest appended suffix, in chars. max_sfx_chars: usize, try_chars: String, @@ -484,14 +491,27 @@ impl AffixDict { /// Build the suffix index and the stem hash table. fn index(&mut self) { let mut idx: FxMap, Vec> = FxMap::default(); + // Each (appended, stripped) pair's group, found by hash rather than by scanning the + // appended string's groups: a dictionary with many strips per suffix stays linear. + let mut group_of: FxMap<(&str, &str), usize> = FxMap::default(); + let mut by_flag: Vec> = vec![Vec::new(); 128]; for (i, r) in self.suffixes.iter().enumerate() { let groups = idx.entry(r.add.clone()).or_default(); let Ok(i) = u32::try_from(i) else { break }; - match groups.iter_mut().find(|g| g.strip == r.strip) { + match group_of.get(&(&*r.add, &*r.strip)).and_then(|&g| groups.get_mut(g)) { Some(g) => g.rules.push(i), - None => groups.push(SfxGroup { strip: r.strip.clone(), rules: vec![i] }), + None => { + group_of.insert((&r.add, &r.strip), groups.len()); + groups.push(SfxGroup { strip: r.strip.clone(), rules: vec![i] }); + } + } + if r.bit != 0 + && let Some(list) = by_flag.get_mut(r.bit.trailing_zeros() as usize) + { + list.push(i); } } + self.sfx_by_flag = by_flag; self.max_sfx_chars = self.suffixes.iter().map(|r| r.add.chars().count()).max().unwrap_or(0); self.sfx_index = idx; let n = self.len(); @@ -596,10 +616,14 @@ impl AffixDict { None } - /// Does `w` carry flag `bit` — as a stem, or as the form of a suffix rule whose - /// continuation classes include `bit` (chained suffixation, depth-bounded)? - fn form_has_flag(&self, w: &str, bit: u128, depth: u8, excluded: u128) -> bool { - if self.stem_flags_excluding(w, excluded).is_some_and(|f| f & bit != 0) { + /// Does `w` carry any flag of `flags` — as a stem, or as the form of a suffix rule whose + /// continuation classes include one (chained suffixation, depth-bounded)? + /// + /// The rules of a group that could lead to `w` are merged into one mask of their flags and + /// followed once, not once per rule: the answer is the same, but thousands of rules of one + /// flag cost one walk, not one each (#3613). + fn form_has_flag(&self, w: &str, flags: u128, depth: u8, excluded: u128) -> bool { + if self.stem_flags_excluding(w, excluded).is_some_and(|f| f & flags != 0) { return true; } if depth == 0 { @@ -614,10 +638,14 @@ impl AffixDict { root.clear(); root.push_str(base); root.push_str(&g.strip); - for r in g.rules.iter().filter_map(|&i| self.suffixes.get(i as usize)) { - if r.cont & bit != 0 && r.cond_matches(&root, true) && self.form_has_flag(&root, r.bit, depth - 1, excluded) { - return true; - } + let inner = g + .rules + .iter() + .filter_map(|&i| self.suffixes.get(i as usize)) + .filter(|r| r.cont & flags != 0 && r.cond_matches(&root, true)) + .fold(0u128, |m, r| m | r.bit); + if inner != 0 && self.form_has_flag(&root, inner, depth - 1, excluded) { + return true; } } } @@ -690,9 +718,13 @@ impl AffixDict { return true; } // Continuation classes: `root` need not be a stem carrying this rule's flag — - // it may be a form that gained the flag from another rule's continuation. - if need == 0 && rules().any(|r| r.cond_matches(&root, true) && self.form_has_flag(&root, r.bit, INNER_CHAIN, excluded)) { - return true; + // it may be a form that gained the flag from another rule's continuation. The + // matching rules' flags are looked for together, in one walk. + if need == 0 { + let flags = rules().filter(|r| r.cond_matches(&root, true)).fold(0u128, |m, r| m | r.bit); + if flags != 0 && self.form_has_flag(&root, flags, INNER_CHAIN, excluded) { + return true; + } } } } @@ -725,13 +757,19 @@ impl AffixDict { } } - /// Every form of the stem `stem` (itself included), unsorted, possibly with repeats. + /// Every form of the stem `stem` (itself included), unsorted, possibly with repeats; at most + /// [`MAX_FORMS`], and at most [`MAX_FORMS_WORK`] rules tried along the continuation chains and + /// cross products, so a dictionary with thousands of self-continuing rules can't make one stem + /// cost millions of strings (#3613). pub fn forms(&self, stem: &str) -> Vec { let Some(f) = self.stem_flags(stem) else { return Vec::new() }; let mut out = vec![stem.to_string()]; let mut cross_sfx = Vec::new(); - // Suffix forms queued with the continuation classes they carry, for chained expansion. + // Suffix forms queued with the continuation classes they carry, for chained expansion; + // each (form, classes) pair is expanded once. let mut chain: Vec<(String, u128)> = Vec::new(); + let mut queued: std::collections::HashSet<(String, u128)> = std::collections::HashSet::new(); + let mut work = 0usize; for r in &self.suffixes { if f & r.bit == 0 || !r.cond_matches(stem, true) { continue; @@ -741,29 +779,39 @@ impl AffixDict { if r.cross { cross_sfx.push(form.clone()); } - if r.cont != 0 { + if r.cont != 0 && queued.insert((form.clone(), r.cont)) { chain.push((form.clone(), r.cont)); } out.push(form); + if out.len() >= MAX_FORMS { + return out; + } } // Continuation classes chain suffixes (`workable/B` → `workables`), up to [`MAX_CHAIN`] // suffixes in all — the same bound the checker walks. - for _ in 0..INNER_CHAIN { + 'chain: for _ in 0..INNER_CHAIN { let level = std::mem::take(&mut chain); if level.is_empty() { break; } for (form, flags) in level { - for r in &self.suffixes { - if flags & r.bit == 0 || !r.cond_matches(&form, true) { + for r in self.suffixes_with(flags).into_iter().filter_map(|i| self.suffixes.get(i as usize)) { + work += 1; + if work > MAX_FORMS_WORK { + break 'chain; + } + if !r.cond_matches(&form, true) { continue; } let Some(base) = form.strip_suffix(&*r.strip) else { continue }; let next = format!("{base}{}", r.add); - if r.cont != 0 { + if r.cont != 0 && queued.insert((next.clone(), r.cont)) { chain.push((next.clone(), r.cont)); } out.push(next); + if out.len() >= MAX_FORMS { + return out; + } } } } @@ -775,15 +823,38 @@ impl AffixDict { out.push(format!("{}{rest}", r.add)); if r.cross { for s in &cross_sfx { + work += 1; + if out.len() >= MAX_FORMS || work > MAX_FORMS_WORK { + return out; + } if let Some(rest) = s.strip_prefix(&*r.strip) { out.push(format!("{}{rest}", r.add)); } } } + if out.len() >= MAX_FORMS { + return out; + } } out } + /// The suffix rules carrying any flag of `mask`, in rule order. + fn suffixes_with(&self, mask: u128) -> Vec { + let mut rules: Vec = Vec::new(); + let mut lists = 0; + for (bit, list) in self.sfx_by_flag.iter().enumerate() { + if bit < 128 && mask & (1u128 << bit) != 0 && !list.is_empty() { + rules.extend_from_slice(list); + lists += 1; + } + } + if lists > 1 { + rules.sort_unstable(); + } + rules + } + /// The `TRY` letters, most frequent first (suggestions try these). pub fn try_chars(&self) -> &str { &self.try_chars @@ -877,8 +948,8 @@ impl AffixDict { /// Read data written by [`AffixDict::to_bytes`]. pub fn from_bytes(data: &[u8]) -> Result { - let raw = - miniz_oxide::inflate::decompress_to_vec_with_limit(data, 64 << 20).map_err(|e| format!("affix dictionary: inflate failed: {e:?}"))?; + let raw = miniz_oxide::inflate::decompress_to_vec_with_limit(data, 64 << 20) + .map_err(|e| format!("affix dictionary: inflate failed: {:?}", e.status))?; let words_at = raw.windows(7).position(|w| w == b"\nWORDS\n").ok_or("affix dictionary: no WORDS section")?; let head = std::str::from_utf8(raw.get(..words_at).unwrap_or_default()).map_err(|_| "affix dictionary: bad utf-8 in the rules")?; let mut lines = head.lines(); @@ -1216,6 +1287,65 @@ pub(crate) mod tests { } } + #[test] + fn continuation_classes_merge_the_flags_of_a_group() { + // `s` under B and under C share one group (same append, same strip): a form carrying + // either flag takes it, and the stem carrying neither doesn't. + let aff = "SET UTF-8\nSFX A N 1\nSFX A 0 able/C .\nSFX D N 1\nSFX D 0 er/B .\nSFX B N 1\nSFX B 0 s .\nSFX C N 1\nSFX C 0 s [e]\n"; + let d = AffixDict::from_hunspell(aff, "2\nwork/A\nplay/D\n", &[]).unwrap(); + assert!(d.contains("workables") && d.contains("players")); + assert!(!d.contains("works") && !d.contains("plays") && !d.contains("workabless")); + assert!(d.forms("work").iter().any(|f| f == "workables") && d.forms("play").iter().any(|f| f == "players")); + } + + /// `n` suffix rules of flag A on one stem, `rule(i)` giving each rule's strip, append and condition. + fn many_rules(n: usize, rule: impl Fn(usize) -> String) -> AffixDict { + let mut aff = format!("SET UTF-8\nSFX A Y {n}\n"); + for i in 0..n { + aff.push_str(&format!("SFX A {}\n", rule(i))); + } + AffixDict::from_hunspell(&aff, "1\nkot/A\n", &[]).unwrap() + } + + #[test] + fn self_continuing_rules_check_in_linear_time() { + // Thousands of empty-append rules that continue with their own flag: each check used to + // follow every rule from every rule, millions of steps a word (#3613). The checks take + // about a millisecond; the old checker took 14 s for two of them at 16,000 rules, so at + // 40,000 (quadratic: minutes) the 10 s bound leaves a busy machine a wide margin. + let d = many_rules(40_000, |_| "0 0/A .".into()); + let t = std::time::Instant::now(); + assert!(d.check("kot") && !d.check("pies") && !d.check("kotx")); + assert!(t.elapsed().as_secs_f64() < 10.0, "{:?}", t.elapsed()); + } + + #[test] + fn forms_of_self_continuing_rules_are_bounded() { + // Every rule's form continues with every rule: millions of strings for one stem (#3613). + let same = many_rules(3000, |_| "0 0/A .".into()); + let distinct = many_rules(3000, |i| format!("0 x{i}/A .")); + let t = std::time::Instant::now(); + let (a, b) = (same.forms("kot"), distinct.forms("kot")); + // The cap on the count is what tells the old code (9 million forms each) apart; the + // generous time bound (tens of ms here) only backs up the rule-visit cap. + assert!(t.elapsed().as_secs_f64() < 10.0, "{:?}", t.elapsed()); + assert!(a.len() <= MAX_FORMS && b.len() <= MAX_FORMS, "{} {}", a.len(), b.len()); + assert!(b.iter().any(|f| f == "kotx0x1"), "chained forms are still listed"); + } + + #[test] + fn many_strips_per_suffix_index_in_linear_time() { + // One append with thousands of strips: the index used to scan the append's groups for + // each rule. Building 200,000 rules takes well under a second; the old index took 3 s at + // 30,000 and over 10 minutes at 300,000. + let n = 200_000; + let t = std::time::Instant::now(); + let d = many_rules(n, |i| format!("k{i} y .")); + assert!(t.elapsed().as_secs_f64() < 10.0, "{:?}", t.elapsed()); + assert_eq!(d.sfx_index.get("y").map(Vec::len), Some(n)); + assert!(!d.contains("koty")); + } + #[test] fn compact_format_round_trips_continuations_and_forbidden() { let aff = "SET UTF-8\nFLAG UTF-8\nFORBIDDENWORD ý\nSFX A N 1\nSFX A 0 able/B .\nSFX B N 1\nSFX B 0 s .\n"; diff --git a/crates/proof/src/dict.rs b/crates/proof/src/dict.rs index 83a54d4437..81eb0de1b4 100644 --- a/crates/proof/src/dict.rs +++ b/crates/proof/src/dict.rs @@ -14,6 +14,11 @@ pub struct Dictionary { } const MAGIC: &[u8] = b"DCHYPH1\n"; +/// Largest inflated dictionary, and most bytes of words kept from one (the bundled US English one +/// is about 1 MB). +const MAX_BYTES: usize = 64 << 20; +/// Most words read from one dictionary (the bundled one has about 160k). +const MAX_ENTRIES: usize = 4_000_000; impl Dictionary { /// Build from `(lowercase word, break mask)` pairs (sorted and de-duplicated here; the first @@ -76,9 +81,16 @@ impl Dictionary { miniz_oxide::deflate::compress_to_vec(&raw, 10) } - /// Deserialize data written by [`Dictionary::to_bytes`]. + /// Deserialize data written by [`Dictionary::to_bytes`]. Data that inflates past + /// [`MAX_BYTES`], or holds more than [`MAX_ENTRIES`] words or [`MAX_BYTES`] of them, is refused. pub fn from_bytes(data: &[u8]) -> Result { - let raw = miniz_oxide::inflate::decompress_to_vec(data).map_err(|e| format!("hyphenation dictionary: inflate failed: {e:?}"))?; + Dictionary::from_bytes_within(data, MAX_BYTES, MAX_ENTRIES) + } + + /// [`Dictionary::from_bytes`] with the limits given. + fn from_bytes_within(data: &[u8], max_bytes: usize, max_entries: usize) -> Result { + let raw = miniz_oxide::inflate::decompress_to_vec_with_limit(data, max_bytes) + .map_err(|e| format!("hyphenation dictionary: inflate failed: {:?}", e.status))?; let body = raw.strip_prefix(MAGIC).ok_or("hyphenation dictionary: bad header")?; let mut d = Dictionary { words: String::with_capacity(body.len()), offs: vec![0], masks: Vec::new() }; let mut cur: Vec = Vec::new(); @@ -94,6 +106,11 @@ impl Dictionary { i = end + 1; let s = std::str::from_utf8(&cur).map_err(|_| "hyphenation dictionary: bad utf-8")?; let (w, m) = parse_hyphenated(s); + // Front coding lets a short line repeat a long prefix: bound what is kept, not only + // what is read. + if d.masks.len() >= max_entries || d.words.len() + w.len() > max_bytes { + return Err("hyphenation dictionary: too large".into()); + } d.words.push_str(&w); d.offs.push(d.words.len() as u32); d.masks.push(m); @@ -147,4 +164,31 @@ mod tests { assert_eq!(back.get("naïveté").map(|m| hyphenated("naïveté", m)), Some("naïve-té".to_string())); assert!(back.get("dog").is_none()); } + + #[test] + fn oversized_data_is_refused() { + const LIMIT: usize = 1 << 16; + let within = |raw: &[u8]| Dictionary::from_bytes_within(&miniz_oxide::deflate::compress_to_vec(raw, 6), LIMIT, 1000); + // Data that inflates past the limit is refused before anything is decoded. + let mut raw = MAGIC.to_vec(); + raw.resize(LIMIT + 1, b'a'); + assert!(within(&raw).unwrap_err().contains("inflate failed")); + // Short lines that each repeat a long prefix: small to inflate, but the words they spell + // out would be ~100x larger. Kept storage is bounded too. + let mut raw = MAGIC.to_vec(); + raw.push(0); + raw.extend_from_slice(&[b'a'; 255]); + raw.push(b'\n'); + for _ in 0..(LIMIT / 255 + 2) { + raw.extend_from_slice(&[255, b'\n']); + } + assert!(raw.len() < LIMIT); + assert!(within(&raw).unwrap_err().contains("too large")); + // So is the number of words. + let words: Vec<(String, u64)> = (0..1001).map(|i| (format!("w{i}"), 0)).collect(); + let raw = miniz_oxide::inflate::decompress_to_vec(&Dictionary::from_entries(words).to_bytes()).unwrap(); + assert!(within(&raw).unwrap_err().contains("too large")); + // The real limits leave the bundled dictionary whole. + assert!(Dictionary::en_us().len() > 100_000); + } } diff --git a/crates/proof/src/es/mod.rs b/crates/proof/src/es/mod.rs index 94c6511abd..351a69b04c 100644 --- a/crates/proof/src/es/mod.rs +++ b/crates/proof/src/es/mod.rs @@ -16,7 +16,7 @@ mod suggest; pub(crate) mod test_dict; pub mod voseo; -use std::sync::{Arc, RwLock}; +use std::sync::{Arc, PoisonError, RwLock}; use crate::ProofLang; use crate::affix::AffixDict; @@ -62,11 +62,11 @@ pub fn dictionary_name(lang: ProofLang) -> &'static str { /// see [`has_dictionary`]). pub fn dictionary(lang: ProofLang) -> Arc { let Some(slot) = DICTS.get(variety(lang).0) else { return Arc::new(AffixDict::default()) }; - if let Some(d) = slot.read().unwrap_or_else(|e| e.into_inner()).as_ref() { + if let Some(d) = slot.read().unwrap_or_else(PoisonError::into_inner).as_ref() { return d.clone(); } let loaded = Arc::new(load_installed(lang)); - let mut slot = slot.write().unwrap_or_else(|e| e.into_inner()); + let mut slot = slot.write().unwrap_or_else(PoisonError::into_inner); // Another thread (or an install) may have filled it meanwhile: that one wins. slot.get_or_insert(loaded).clone() } @@ -90,7 +90,7 @@ fn load_installed(lang: ProofLang) -> AffixDict { pub fn install_dictionary(lang: ProofLang, aff: &str, dic: &str) -> Result<(), String> { let d = AffixDict::from_hunspell(aff, dic, &[])?; if let Some(slot) = DICTS.get(variety(lang).0) { - *slot.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(d)); + *slot.write().unwrap_or_else(PoisonError::into_inner) = Some(Arc::new(d)); } Ok(()) } @@ -99,7 +99,7 @@ pub fn install_dictionary(lang: ProofLang, aff: &str, dic: &str) -> Result<(), S /// (after [`crate::hunspell::set_dictionary_dir`] or a dictionary was installed). pub fn reload_dictionary() { for slot in &DICTS { - *slot.write().unwrap_or_else(|e| e.into_inner()) = None; + *slot.write().unwrap_or_else(PoisonError::into_inner) = None; } } diff --git a/crates/proof/src/hunspell.rs b/crates/proof/src/hunspell.rs index 0e4303beb2..3449c8cab7 100644 --- a/crates/proof/src/hunspell.rs +++ b/crates/proof/src/hunspell.rs @@ -140,16 +140,31 @@ pub fn find_in(dir: &Path, names: &[&str]) -> Option<(PathBuf, PathBuf)> { }) } +/// Read a dictionary file: a regular file of at most [`MAX_FILE`] bytes. The read itself is +/// bounded, so a file that grows after its size is checked (or a filesystem that reports a wrong +/// size) can't make it read more (#3661). +pub(crate) fn read_capped(p: &Path) -> Result, String> { + use std::io::Read; + let err = |e: std::io::Error| format!("{}: {e}", p.display()); + let f = std::fs::File::open(p).map_err(err)?; + let meta = f.metadata().map_err(err)?; + if !meta.is_file() { + return Err(format!("{}: not a regular file", p.display())); + } + if meta.len() > MAX_FILE { + return Err(format!("{}: too large ({} bytes)", p.display(), meta.len())); + } + let mut buf = Vec::new(); + f.take(MAX_FILE + 1).read_to_end(&mut buf).map_err(err)?; + if buf.len() as u64 > MAX_FILE { + return Err(format!("{}: too large", p.display())); + } + Ok(buf) +} + /// Read a Hunspell pair from disk (size-capped), decoded per the `.aff`'s `SET` line. pub fn load(aff: &Path, dic: &Path) -> Result { - let read = |p: &Path| -> Result, String> { - let len = std::fs::metadata(p).map_err(|e| format!("{}: {e}", p.display()))?.len(); - if len > MAX_FILE { - return Err(format!("{}: too large ({len} bytes)", p.display())); - } - std::fs::read(p).map_err(|e| format!("{}: {e}", p.display())) - }; - let (aff_text, dic_text) = decode(&read(aff)?, &read(dic)?)?; + let (aff_text, dic_text) = decode(&read_capped(aff)?, &read_capped(dic)?)?; let source = format!("Hunspell dictionary {}", dic.display()); AffixDict::from_hunspell(&aff_text, &dic_text, &[source.as_str()]) } @@ -159,6 +174,23 @@ fn decode_latin1(bytes: &[u8]) -> String { bytes.iter().map(|&b| char::from(b)).collect() } +/// ISO 8859-9 (Latin-5, Turkish): ISO 8859-1 with Ğ İ Ş ğ ı ş in place of Ð Ý Þ ð ý þ. The +/// encoding of older Turkish dictionaries (`SET ISO8859-9`). +fn decode_latin5(bytes: &[u8]) -> String { + bytes + .iter() + .map(|&b| match b { + 0xd0 => 'Ğ', + 0xdd => 'İ', + 0xde => 'Ş', + 0xf0 => 'ğ', + 0xfd => 'ı', + 0xfe => 'ş', + _ => char::from(b), + }) + .collect() +} + /// ISO 8859-2 code points 0xA0..=0xFF (0x00..=0x9F map to themselves). const ISO_8859_2_HIGH: &str = "\u{a0}Ą˘Ł¤ĽŚ§¨ŠŞŤŹ\u{ad}ŽŻ°ą˛ł´ľśˇ¸šşťź˝žżŔÁÂĂÄĹĆÇČÉĘËĚÍÎĎĐŃŇÓÔŐÖ×ŘŮÚŰÜÝŢßŕáâăäĺćçčéęëěíîďđńňóôőö÷řůúűüýţ˙"; @@ -184,7 +216,8 @@ fn decode_high(bytes: &[u8], high: &str) -> String { /// Decode an `.aff`/`.dic` pair to UTF-8 from the encoding the `.aff`'s `SET` line names /// (UTF-8 when there is none; ISO 8859-2 is the other one Polish dictionaries use, ISO 8859-1 the -/// one the original Portuguese VERO uses, KOI8-R and Windows-1251 the ones older Russian dictionaries use). +/// one the original Portuguese VERO uses, ISO 8859-9 the one older Turkish dictionaries use, KOI8-R +/// and Windows-1251 the ones older Russian dictionaries use). pub fn decode(aff: &[u8], dic: &[u8]) -> Result<(String, String), String> { let head = String::from_utf8_lossy(aff.get(..aff.len().min(8192)).unwrap_or_default()).to_string(); let set = head.lines().find_map(|l| l.trim_start_matches('\u{feff}').strip_prefix("SET ")).map(str::trim).unwrap_or("UTF-8").to_ascii_uppercase(); @@ -195,6 +228,7 @@ pub fn decode(aff: &[u8], dic: &[u8]) -> Result<(String, String), String> { } "ISO8859-1" | "ISO-8859-1" => Ok((decode_latin1(aff), decode_latin1(dic))), "ISO8859-2" | "ISO-8859-2" => Ok((decode_latin2(aff), decode_latin2(dic))), + "ISO8859-9" | "ISO-8859-9" => Ok((decode_latin5(aff), decode_latin5(dic))), "KOI8-R" | "KOI8R" => Ok((decode_high(aff, KOI8_R_HIGH), decode_high(dic, KOI8_R_HIGH))), "CP1251" | "WINDOWS-1251" | "MICROSOFT-CP1251" => Ok((decode_high(aff, CP1251_HIGH), decode_high(dic, CP1251_HIGH))), other => Err(format!("unsupported encoding {other}")), @@ -213,6 +247,8 @@ mod tests { assert_eq!(d, "2\nżółw\n"); let (_, d) = decode("SET UTF-8\n".as_bytes(), "żółw".as_bytes()).unwrap(); assert_eq!(d, "żółw"); + let (_, d) = decode(b"SET ISO8859-9\n", b"\xd0\xdd\xde\xf0\xfd\xfe\xd6\xe7").unwrap(); + assert_eq!(d, "ĞİŞğışÖç"); assert!(decode(b"SET SHIFT_JIS\n", b"").is_err()); assert!(decode(b"SET UTF-8\n", b"\xff\xfe").is_err()); } @@ -283,4 +319,52 @@ mod tests { assert!(load(&dir.join("missing.aff"), &dic).is_err()); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn reads_are_bounded_and_regular_files_only() { + let dir = std::env::temp_dir().join(format!("wordcraft-hunspell-cap-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let aff = dir.join("xx.aff"); + std::fs::write(&aff, "SET UTF-8\n").unwrap(); + // One byte over the cap (sparse: nothing is written) is refused without reading it. + let big = dir.join("big.dic"); + std::fs::File::create(&big).unwrap().set_len(MAX_FILE + 1).unwrap(); + assert!(load(&aff, &big).unwrap_err().contains("too large")); + // A directory, and on Unix an endless device, are not dictionary files. (No clock: a + // reader that read `/dev/zero` would never return, so the error itself is the proof.) + assert!(load(&aff, &dir).unwrap_err().contains("not a regular file")); + #[cfg(unix)] + assert!(load(&aff, Path::new("/dev/zero")).unwrap_err().contains("not a regular file")); + // Turkish reads through the same reader. + let (tr_aff, tr_dic) = (dir.join("tr_TR.aff"), dir.join("tr_TR.dic")); + std::fs::write(&tr_aff, "SET UTF-8\nSFX 1 Y 1\nSFX 1 0 lar .\n").unwrap(); + std::fs::write(&tr_dic, "1\nkitap/1\n").unwrap(); + assert!(crate::tr::load(&tr_aff, &tr_dic).unwrap().contains("kitaplar")); + assert!(crate::tr::load(&tr_aff, &big).unwrap_err().contains("too large")); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A Turkish dictionary in ISO 8859-9, the encoding older tr_TR dictionaries use, read from + /// disk through the capped reader and decoded per its `SET` line: its words with Turkish + /// letters (ğ ı ş İ, and ç ö ü from Latin-1) are found, as stems and with a suffix. Before the + /// Turkish dictionary went through this reader it was read as UTF-8, which these bytes are not. + #[test] + fn a_turkish_dictionary_in_iso_8859_9_loads_from_disk() { + let dir = std::env::temp_dir().join(format!("wordcraft-hunspell-latin5-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (aff, dic) = (dir.join("tr_TR.aff"), dir.join("tr_TR.dic")); + std::fs::write(&aff, b"SET ISO8859-9\nSFX 1 Y 2\nSFX 1 0 lar .\nSFX 1 0 \xfd .\nSFX 2 Y 1\nSFX 2 0 ler .\n").unwrap(); + // ağaç/1 (-lar, -ı), ışık, şişe/2 (-ler), İzmir, öğrenci/2, güneş. + let words: &[u8] = b"6\na\xf0a\xe7/1\n\xfd\xfe\xfdk\n\xfei\xfee/2\n\xddzmir\n\xf6\xf0renci/2\ng\xfcne\xfe\n"; + std::fs::write(&dic, words).unwrap(); + assert!(String::from_utf8(words.to_vec()).is_err(), "the .dic is not UTF-8"); + let d = crate::tr::load(&aff, &dic).unwrap(); + for w in ["ağaç", "ağaçlar", "ağaçı", "ışık", "şişe", "şişeler", "İzmir", "öğrenci", "öğrenciler", "güneş"] { + assert!(d.contains(w), "{w}"); + } + // The six letters ISO 8859-9 puts in place of ISO 8859-1's aren't read as those. + assert!(!d.contains("aðaç") && !d.contains("ýþýk") && !d.contains("Ýzmir")); + assert!(!d.contains("ağaçs") && !d.contains("ışıklar") && !d.contains("şişelar"), "only the rules' suffixes, on the stems that take them"); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/proof/src/hyphen.rs b/crates/proof/src/hyphen.rs index ed9f77f9de..b203ff1a2a 100644 --- a/crates/proof/src/hyphen.rs +++ b/crates/proof/src/hyphen.rs @@ -320,10 +320,14 @@ pub fn hyphenate_word(word: &str, lim: &Limits) -> String { /// `word`, a word in `lang`, with `-` at every hyphenation point (tests and diagnostics). pub fn hyphenate_word_in(word: &str, lim: &Limits, lang: ProofLang) -> String { - let pts = hyphen_points_in(word, lim, lang); - let mut s = String::with_capacity(word.len() + pts.len()); + let mut pts = hyphen_points_in(word, lim, lang); + pts.sort_unstable(); + // Walk the points alongside the chars rather than searching them for each char. + let mut next = pts.into_iter().peekable(); + let mut s = String::with_capacity(word.len() + 8); for (i, ch) in word.chars().enumerate() { - if pts.contains(&i) { + if next.next_if_eq(&i).is_some() { + while next.next_if_eq(&i).is_some() {} s.push('-'); } s.push(ch); @@ -706,6 +710,26 @@ mod tests { } } + #[test] + fn russian_hyphenation_and_hyphenated_output_are_linear() { + // A Russian word breaks at every syllable: each break used to be checked against all the + // earlier ones (#3657), and the hyphenated output searched every break for each char. + let word = |k: usize| "мама".repeat(k); + assert_eq!(word_breaks_in(&word(2), ProofLang::Ru).0, [2, 4, 6]); + assert_eq!(hyphenate_word_in(&word(2), &Limits::default(), ProofLang::Ru), "ма-ма-ма-ма"); + // The breaks' rescan is a cheap one per break: it takes longer words to stand out. + for (what, k, repeat, f) in [ + ("points", 1000, 3, (|w: &str| drop(hyphen_points_in(w, &Limits::default(), ProofLang::Ru))) as fn(&str)), + ("hyphenated", 400, 20, |w: &str| drop(hyphenate_word_in(w, &Limits::default(), ProofLang::Ru))), + ] { + let (small, big) = (word(k), word(8 * k)); + let time = |w: &str| (0..repeat).for_each(|_| f(w)); + let (t1, t8, ratio) = cost_ratio(8, || time(&small), || time(&big)); + eprintln!("{what}: t(n) {t1:.4} s, t(8n) {t8:.4} s, ratio {ratio:.1}"); + assert!(ratio < 24.0, "8x the letters, not linear: {what} {ratio:.1}"); + } + } + #[test] fn heuristic_fallback() { let c: Vec = "happen".chars().collect(); diff --git a/crates/proof/src/ru/hyphen.rs b/crates/proof/src/ru/hyphen.rs index c02aa12728..33d19b7712 100644 --- a/crates/proof/src/ru/hyphen.rs +++ b/crates/proof/src/ru/hyphen.rs @@ -57,7 +57,9 @@ pub fn points(c: &[char]) -> Vec { while c.get(cut).is_some_and(|&x| sticks_left(x)) && cut < end { cut += 1; } - if cut >= 2 && n - cut >= 2 && !out.contains(&cut) { + // Cuts come in increasing order (each lies after the previous pair's vowel), so a repeat + // can only be the last one: no scan of the whole list per syllable (#3657). + if cut >= 2 && n - cut >= 2 && out.last().is_none_or(|&l| l < cut) { out.push(cut); } } diff --git a/crates/proof/src/tr/dict.rs b/crates/proof/src/tr/dict.rs index ed57cbca7b..4b59ccc297 100644 --- a/crates/proof/src/tr/dict.rs +++ b/crates/proof/src/tr/dict.rs @@ -87,7 +87,8 @@ impl TrDict { /// Check if word is correct (stem or affixed). pub fn contains(&self, word: &str) -> bool { - if self.is_empty() || word.is_empty() { + // Longer input is never a dictionary word, and isn't scanned (#3625). + if self.is_empty() || word.is_empty() || word.len() > crate::affix::MAX_WORD_BYTES { return false; } diff --git a/crates/proof/src/tr/mod.rs b/crates/proof/src/tr/mod.rs index bcab1546bf..25ee424c42 100644 --- a/crates/proof/src/tr/mod.rs +++ b/crates/proof/src/tr/mod.rs @@ -44,18 +44,17 @@ fn load_installed() -> TrDict { log::info!("Turkish spelling: no Hunspell tr_TR dictionary installed"); return TrDict::default(); }; - let read = |p: &std::path::Path| std::fs::read_to_string(p); - match (read(&aff), read(&dic)) { - (Ok(aff_text), Ok(dic_text)) => crate::hunspell::note_load(NAMES[0], &dic, TrDict::from_hunspell(&aff_text, &dic_text)).unwrap_or_else(|e| { - log::warn!("Turkish spelling: {e}"); - TrDict::default() - }), - (a, d) => { - let reason = a.err().or(d.err()).map_or_else(String::new, |e| e.to_string()); - let _ = crate::hunspell::note_load::<()>(NAMES[0], &dic, Err(reason)); - TrDict::default() - } - } + crate::hunspell::note_load(NAMES[0], &dic, load(&aff, &dic)).unwrap_or_else(|e| { + log::warn!("Turkish spelling: {e}"); + TrDict::default() + }) +} + +/// Read a Turkish Hunspell pair from disk: size-capped and decoded per the `.aff`'s `SET` line, +/// like the other languages' dictionaries (#3661). +pub(crate) fn load(aff: &std::path::Path, dic: &std::path::Path) -> Result { + let (aff_text, dic_text) = crate::hunspell::decode(&crate::hunspell::read_capped(aff)?, &crate::hunspell::read_capped(dic)?)?; + TrDict::from_hunspell(&aff_text, &dic_text) } /// Use this Turkish dictionary from now on. diff --git a/crates/proof/src/tr/suggest.rs b/crates/proof/src/tr/suggest.rs index dedc1bb266..c5f4db8959 100644 --- a/crates/proof/src/tr/suggest.rs +++ b/crates/proof/src/tr/suggest.rs @@ -46,6 +46,12 @@ const COMMON_REPLACEMENTS: &[(&str, &str)] = &[ ("deyil", "değil"), ]; +/// Longest word (in letters) given suggestions. Every candidate is a fresh copy of the word, so +/// the cost grows with the square of its length (#3625); Turkish words run longer than most +/// (`Çekoslovakyalılaştıramadıklarımızdanmışsınız` is 43 letters), so the cap is above the other +/// languages' 40. +const MAX_LETTERS: usize = 64; + /// Produce spelling suggestions for a misspelled Turkish word. pub fn suggest(word: &str, max: usize) -> Vec { let dict = super::dictionary(); @@ -54,7 +60,8 @@ pub fn suggest(word: &str, max: usize) -> Vec { /// Suggest with a given dictionary. pub fn suggest_with(d: &TrDict, word: &str, max: usize) -> Vec { - if word.is_empty() || max == 0 { + let n = word.chars().count(); + if n == 0 || n > MAX_LETTERS || max == 0 { return Vec::new(); } @@ -72,6 +79,10 @@ pub fn suggest_with(d: &TrDict, word: &str, max: usize) -> Vec { } } } + // Only the table takes more than one word (`bir kaç`). + if word.contains(char::is_whitespace) { + return suggestions; + } // 2. Diacritic substitutions (`c` -> `ç`, `g` -> `ğ`, `ı` -> `i`, `o` -> `ö`, `s` -> `ş`, `u` -> `ü`) if !d.is_empty() { @@ -277,6 +288,28 @@ mod tests { assert!(sugs.contains(&"geldin mi".to_string())); } + #[test] + fn overlong_words_get_no_suggestions_promptly() { + let d = TrDict::from_hunspell("SET UTF-8\nSFX 1 Y 1\nSFX 1 0 lar .\n", "2\nkitap/1\nsonra\n").unwrap(); + assert!(suggest_with(&d, "sorna", 3).contains(&"sonra".to_string())); + assert!(suggest_with(&d, "kitaplr", 3).contains(&"kitaplar".to_string())); + let t = std::time::Instant::now(); + let long = "a".repeat(100_000); + assert!(suggest_with(&d, &long, 8).is_empty()); + assert!(!d.contains(&long) && !d.contains(&format!("kitap{long}"))); + assert!(!super::super::is_correct_with(&d, &long)); + // Tens of milliseconds with the caps; without them this ran for over 5 minutes. + assert!(t.elapsed().as_secs_f64() < 10.0, "{:?}", t.elapsed()); + } + + #[test] + fn two_words_get_only_the_tables_replacement() { + let d = TrDict::from_hunspell("SET UTF-8\n", "2\nbirkaç\nsonra\n").unwrap(); + assert_eq!(suggest_with(&d, "bir kaç", 3), vec!["birkaç".to_string()]); + assert_eq!(suggest_with(&d, "Bir kaç", 3), vec!["birkaç".to_string()]); + assert!(suggest_with(&d, "sorna sorna", 3).is_empty()); + } + #[test] fn turkish_to_lower_handles_i() { assert_eq!(turkish_to_lower("IŞIK"), "ışık"); diff --git a/crates/render/src/color_glyph.rs b/crates/render/src/color_glyph.rs index 8b169d9b6c..92c321296c 100644 --- a/crates/render/src/color_glyph.rs +++ b/crates/render/src/color_glyph.rs @@ -23,7 +23,7 @@ pub(crate) enum Drawable { impl Drawable { fn bytes(&self) -> usize { match self { - Drawable::Layers(ops) => ops.len().saturating_mul(64), + Drawable::Layers(ops) => wordcraft_fonts::color::ops_bytes(ops), Drawable::Bitmap { pixmap, .. } => pixmap.width() as usize * pixmap.height() as usize * 4, } } @@ -102,18 +102,11 @@ fn decode_png(png: &[u8], w: u32, h: u32) -> Option { if w == 0 || h == 0 || w > side || h > side { return None; } - let reader = || image::ImageReader::with_format(std::io::Cursor::new(png), image::ImageFormat::Png); let fits = |(iw, ih): (u32, u32)| iw > 0 && ih > 0 && iw <= w && ih <= h; - if !fits(reader().into_dimensions().ok()?) { + if !fits(glyph_png_reader(png, w, h).into_dimensions().ok()?) { return None; } - let mut limits = image::Limits::default(); - limits.max_image_width = Some(w); - limits.max_image_height = Some(h); - limits.max_alloc = Some(w as u64 * h as u64 * 4 * 2 + 64 * 1024); - let mut r = reader(); - r.limits(limits); - let img = r.decode().ok()?.to_rgba8(); + let img = glyph_png_reader(png, w, h).decode().ok()?.to_rgba8(); let (iw, ih) = img.dimensions(); if !fits((iw, ih)) { return None; @@ -121,6 +114,19 @@ fn decode_png(png: &[u8], w: u32, h: u32) -> Option { crate::premul_pixmap(iw, ih, img.as_raw()) } +/// A reader for a bitmap glyph's PNG with the glyph's budget: at most `w`×`h` and about two RGBA +/// buffers of it. Every read goes through it, the header probe too: reading the header inflates +/// metadata such as an ICC profile, which the default budget would let run to hundreds of MB. +fn glyph_png_reader(png: &[u8], w: u32, h: u32) -> image::ImageReader> { + let mut limits = image::Limits::default(); + limits.max_image_width = Some(w); + limits.max_image_height = Some(h); + limits.max_alloc = Some(u64::from(w).saturating_mul(u64::from(h)).saturating_mul(8).saturating_add(64 * 1024)); + let mut r = image::ImageReader::with_format(std::io::Cursor::new(png), image::ImageFormat::Png); + r.limits(limits); + r +} + /// A pixmap from premultiplied BGRA rows. fn bgra_pixmap(bgra: &[u8], w: u32, h: u32) -> Option { if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 { @@ -436,4 +442,24 @@ mod tests { let side = wordcraft_fonts::color::MAX_BITMAP_SIDE; assert!(decode_png(&png(4, 4), side + 1, 4).is_none(), "past the cap"); } + + /// A tiny bitmap glyph whose PNG carries a big ICC profile (it compresses to almost nothing): + /// the header probe already inflates it, so the probe is held to the glyph's budget too, not + /// the decoder's default hundreds of MB. Past the budget the profile is dropped (the glyph is + /// still drawn, without it). + #[test] + fn bitmap_png_header_probe_keeps_the_glyph_budget() { + use image::ImageDecoder; + let mut out = vec![]; + let mut enc = image::codecs::png::PngEncoder::new(&mut out); + image::ImageEncoder::set_icc_profile(&mut enc, vec![0u8; 4 << 20]).unwrap(); + image::ImageEncoder::write_image(enc, &[200, 0, 0, 255], 1, 1, image::ExtendedColorType::Rgba8).unwrap(); + assert!(out.len() < 64 * 1024, "the profile compresses: {} bytes", out.len()); + let profile = |r: image::ImageReader>| r.into_decoder().unwrap().icc_profile().unwrap().map(|p| p.len()); + let unbounded = image::ImageReader::with_format(std::io::Cursor::new(out.as_slice()), image::ImageFormat::Png); + assert_eq!(profile(unbounded), Some(4 << 20), "the default budget inflates the 4 MB profile"); + assert_eq!(profile(glyph_png_reader(&out, 1, 1)), None, "the glyph budget stops it at the header"); + assert_eq!(decode_png(&out, 1, 1).map(|p| (p.width(), p.height())), Some((1, 1))); + assert_eq!(glyph_png_reader(&png(4, 4), 4, 4).into_dimensions().ok(), Some((4, 4)), "a plain glyph reads"); + } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 78b5ff1107..318904fbe3 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -57,15 +57,21 @@ impl Rendered { } out } + /// Whether the pixels are exactly `width`×`height` RGBA, within a page raster's budget + /// ([`wordcraft_geom::MAX_RASTER_PIXELS`]): the fields are public, so an encoder is never asked + /// to pad a short buffer out to a huge claimed size (or, for JPEG, to assert on a mismatch). + fn encodable(&self) -> bool { + let budget = wordcraft_geom::MAX_RASTER_PIXELS.saturating_mul(4); + wordcraft_geom::rgba_fits(self.width, self.height, budget) + && wordcraft_geom::rgba_bytes(self.width, self.height).is_some_and(|b| b == self.pixels.len() as u64) + } /// PNG bytes (empty on failure, logged). pub fn to_png(&self) -> Vec { - let mut px = self.to_straight(); - let Some(len) = wordcraft_geom::rgba_bytes(self.width, self.height).and_then(|b| usize::try_from(b).ok()) else { - log::error!("PNG encode: size {}×{} overflows", self.width, self.height); + if !self.encodable() { + log::error!("PNG encode: {} bytes for {}×{} pixels", self.pixels.len(), self.width, self.height); return Vec::new(); - }; - px.resize(len, 0); - let Some(img) = image::RgbaImage::from_raw(self.width, self.height, px) else { + } + let Some(img) = image::RgbaImage::from_raw(self.width, self.height, self.to_straight()) else { log::error!("PNG encode: bad size {}×{}", self.width, self.height); return Vec::new(); }; @@ -76,7 +82,12 @@ impl Rendered { } buf } + /// JPEG bytes on white (empty on failure, logged). pub fn to_jpeg(&self, quality: u8) -> Vec { + if !self.encodable() { + log::error!("JPEG encode: {} bytes for {}×{} pixels", self.pixels.len(), self.width, self.height); + return Vec::new(); + } let rgba = self.to_straight(); let rgb: Vec = rgba .as_chunks::<4>() @@ -209,17 +220,30 @@ pub fn decode_pixmap(bytes: &[u8]) -> Option { /// [`decode_pixmap`] with an explicit RGBA byte budget (a small one in tests). fn decode_pixmap_within(bytes: &[u8], budget: u64) -> Option { - // The header gives the size; refuse before the decoder (and the RGBA copy) allocate anything. - let (hw, hh) = image_size(bytes)?; - if !wordcraft_geom::decode_fits(hw, hh, budget) { - return None; - } - let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; + use image::ImageDecoder; let mut limits = image::Limits::default(); limits.max_image_width = Some(MAX_DECODE_SIDE); limits.max_image_height = Some(MAX_DECODE_SIDE); - reader.limits(limits); - let img = reader.decode().ok()?.into_rgba8(); + let reader = || { + let mut r = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; + r.limits(limits.clone()); + Some(r) + }; + // The header gives the size and the decoder's pixel format (16-bit RGBA takes 8 bytes a pixel + // before its RGBA8 copy); refuse before the decoder (and the RGBA copy) allocate anything. + let (hw, hh, native) = { + let d = reader()?.into_decoder().ok()?; + let (w, h) = d.dimensions(); + (w, h, u64::from(d.color_type().bytes_per_pixel())) + }; + if !wordcraft_geom::decode_peak_fits(hw, hh, native, budget) { + // Logged once a picture: its refusal is cached with it (`pixmap_for`). + log::warn!( + "a {hw}×{hh} picture of {native} bytes a pixel needs more than {budget} bytes to decode: it is not drawn (in the text, a grey box takes its place)" + ); + return None; + } + let img = reader()?.decode().ok()?.into_rgba8(); let (w, h) = img.dimensions(); premul_pixmap(w, h, img.as_raw()) } @@ -390,11 +414,46 @@ fn draw_vector_text(ctx: &mut RenderContext, t: &wordcraft_metafile::Text, tf: A let k = lay.em / face.upem.max(1.0); ctx.set_paint(rgba_color(t.color)); let db = FontDb::global(); + let canvas = kurbo::Rect::new(0.0, 0.0, f64::from(ctx.width()), f64::from(ctx.height())); + let mut oversized = 0usize; for (g, (x, y)) in shaped.iter().zip(&lay.glyphs) { let o = db.outline(&face, g.gid); - ctx.set_transform(tf * Affine::translate((*x, *y)) * Affine::scale(k)); + let m = tf * Affine::translate((*x, *y)) * Affine::scale(k); + // A record's font size is file data: a glyph many canvases tall (or off the canvas) would + // have its outline flattened into millions of segments for nothing, so it is left out. + let b = m.transform_rect_bbox(o.bounding_box()); + let sane = [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) && b.width() <= MAX_GLYPH_PIXELS && b.height() <= MAX_GLYPH_PIXELS; + if !sane { + oversized = oversized.saturating_add(1); + continue; + } + if !b.overlaps(canvas) { + continue; + } + ctx.set_transform(m); ctx.fill_path(&o); } + note_oversized_glyphs(&OVERSIZED_GLYPHS_NOTED, oversized); +} + +/// Largest metafile glyph drawn, in device pixels per side (four times the widest canvas). +const MAX_GLYPH_PIXELS: f64 = 65_536.0; + +/// Whether metafile glyphs left out for their size have been logged ([`note_oversized_glyphs`]). +static OVERSIZED_GLYPHS_NOTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// Logs that `n` glyphs of a metafile text run were left out for being larger than +/// [`MAX_GLYPH_PIXELS`] (or of no finite size), the first time any are this run (`noted`): a +/// page is drawn again on every repaint, so a warning each time would flood the log. True when +/// this call logged it. +fn note_oversized_glyphs(noted: &std::sync::atomic::AtomicBool, n: usize) -> bool { + if n == 0 || noted.swap(true, std::sync::atomic::Ordering::Relaxed) { + return false; + } + log::warn!( + "a picture's text has {n} letter(s) larger than {MAX_GLYPH_PIXELS} pixels drawn, left out (logged once a run; later ones are left out too)" + ); + true } /// One device pixel in page points under `view` (its mean axis scale); 1 for a degenerate transform. @@ -855,6 +914,44 @@ fn wave_path(x0: f64, y0: f64, x1: f64, y1: f64, amp: f64) -> BezPath { p } +/// Most pieces one dashed stroke may be cut into; past it the stroke is drawn solid. kurbo's dash +/// iterator makes no progress along a segment when a dash is too small next to it (it never ends), +/// and every piece is more stroke to build. +const MAX_DASH_PIECES: f64 = 100_000.0; + +/// `dashes` to stroke a path at most `len` long with, or none (solid) when they can't be drawn: a +/// negative or non-finite length, a pattern that adds up to nothing, or more than +/// [`MAX_DASH_PIECES`] pieces along the path. +fn safe_dashes(dashes: impl IntoIterator, len: f64) -> Vec { + let d: Vec = dashes.into_iter().take(MAX_DASH_PIECES as usize + 1).collect(); + let period: f64 = d.iter().sum(); + let ok = d.iter().all(|v| v.is_finite() && *v >= 0.0) && period > 0.0 && period.is_finite() && len.is_finite(); + // Each repeat of the pattern is up to `d.len()` pieces; a partial repeat at each end counts too. + if ok && (len / period + 2.0) * d.len() as f64 <= MAX_DASH_PIECES { d } else { Vec::new() } +} + +/// An upper bound on `path`'s length: its control polygon's (a curve is no longer than it). +fn path_len_bound(path: &BezPath) -> f64 { + let (mut len, mut at, mut start) = (0.0, kurbo::Point::ZERO, kurbo::Point::ZERO); + for el in path.elements() { + let pts: &[kurbo::Point] = match el { + kurbo::PathEl::MoveTo(p) => { + (at, start) = (*p, *p); + continue; + } + kurbo::PathEl::LineTo(p) => std::slice::from_ref(p), + kurbo::PathEl::QuadTo(a, b) => &[*a, *b], + kurbo::PathEl::CurveTo(a, b, c) => &[*a, *b, *c], + kurbo::PathEl::ClosePath => std::slice::from_ref(&start), + }; + for p in pts { + len += at.distance(*p); + at = *p; + } + } + len +} + /// Rasterize one display item with `view` mapping its current point coordinates to pixels. /// `visible` is a culling area in those point coordinates; nested clips intersect it and turns /// transform it into the child's coordinates. Shape and path strokes apply their stored caps, @@ -883,10 +980,10 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl // every end, blobbing over the neighbouring border at a table's corners (#2892). Wavy // underlines keep round ends, as the PDF draws them. let mut st = kurbo::Stroke::new(w).with_caps(kurbo::Cap::Butt); - if let Some(d) = stroke.dashes() { - st = st.with_dashes(0.0, d.iter().map(|k| f64::from(*k) * w)); - } let (x0, y0, x1, y1) = (*x0 as f64, *y0 as f64, *x1 as f64, *y1 as f64); + if let Some(d) = stroke.dashes() { + st = st.with_dashes(0.0, safe_dashes(d.iter().map(|k| f64::from(*k) * w), (x1 - x0).hypot(y1 - y0))); + } let line = |ctx: &mut RenderContext, ox: f64, oy: f64| { let mut p = BezPath::new(); p.move_to((x0 + ox, y0 + oy)); @@ -1062,7 +1159,11 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl // Width 0 (or none) is a hairline. let w = if stroke_width.is_finite() && *stroke_width > 0.0 { stroke_width.clamp(0.25, 200.0) } else { 0.75 }; ctx.set_paint(color(opts.ink(*s), 1.0)); - ctx.set_stroke(kurbo::Stroke::new(w as f64).with_caps(screen_cap(*cap)).with_dashes(0.0, dash.iter().map(|v| f64::from(*v)))); + ctx.set_stroke( + kurbo::Stroke::new(w as f64) + .with_caps(screen_cap(*cap)) + .with_dashes(0.0, safe_dashes(dash.iter().map(|v| f64::from(*v)), path_len_bound(&path))), + ); ctx.stroke_path(&path); } } @@ -1153,7 +1254,7 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl ctx.set_stroke( kurbo::Stroke::new(if stroke_width.is_nan() { 0.25 } else { stroke_width.clamp(0.25, 200.0) } as f64) .with_caps(screen_cap(*cap)) - .with_dashes(0.0, dash.iter().map(|v| f64::from(*v))), + .with_dashes(0.0, safe_dashes(dash.iter().map(|v| f64::from(*v)), path_len_bound(&path))), ); ctx.stroke_path(&path); } @@ -1719,12 +1820,36 @@ mod tests { // Decode, the RGBA conversion and the premultiplied copy are charged: two buffers at peak. assert!(decode_pixmap_within(&buf, 1024).is_some()); assert!(decode_pixmap_within(&buf, 1023).is_none()); + // 16-bit RGBA decodes to 8 bytes a pixel, alive with its RGBA8 copy: 12 bytes a pixel. + let mut wide = Vec::new(); + let img16 = image::ImageBuffer::, _>::from_fn(16, 8, |x, y| image::Rgba([x as u16 * 999, y as u16, 9, 65535])); + image::DynamicImage::ImageRgba16(img16).write_to(&mut std::io::Cursor::new(&mut wide), image::ImageFormat::Png).unwrap(); + assert!(decode_pixmap_within(&wide, 1024).is_none(), "two RGBA8 buffers' worth is not enough for RGBA16"); + assert!(decode_pixmap_within(&wide, 1536).is_some()); // A header (valid CRC) claiming 60000x60000 with no pixel data reads fine, then the budget refuses it. let hdr = ihdr_only_png(60_000, 60_000); assert_eq!(image_size(&hdr), Some((60_000, 60_000))); assert!(decode_pixmap(&hdr).is_none()); } + /// A `Rendered` whose pixels don't match its claimed size isn't padded out to that size: a + /// 60000×60000 claim over no pixels would allocate 14 GB. It, and any mismatch, encodes to + /// nothing (JPEG used to assert on the length). + #[test] + fn encoders_refuse_pixels_that_dont_match_the_size() { + let huge = Rendered { width: 60_000, height: 60_000, pixels: Vec::new() }; + assert!(huge.to_png().is_empty()); + assert!(huge.to_jpeg(80).is_empty()); + let short = Rendered { width: 2, height: 2, pixels: vec![255; 4] }; + assert!(short.to_png().is_empty()); + assert!(short.to_jpeg(80).is_empty()); + let long = Rendered { width: 1, height: 1, pixels: vec![255; 8] }; + assert!(long.to_png().is_empty()); + let ok = Rendered { width: 2, height: 2, pixels: vec![255; 16] }; + assert!(!ok.to_png().is_empty()); + assert!(!ok.to_jpeg(80).is_empty()); + } + #[test] fn page_raster_is_capped_by_total_pixels() { let d = Document::new(); @@ -2335,6 +2460,56 @@ mod tests { assert!(dark > 50, "the text is painted inside the picture: {dark} dark pixels"); } + /// An EMF text run whose anchor sits inside the picture but whose font is billions of units + /// tall: each glyph's curves would span billions of device pixels, which the rasteriser + /// subdivides for ever. Glyphs that big (or off the raster) are left out; the page renders. + #[test] + fn metafile_text_with_a_huge_font_is_left_out() { + let mut font = emf_rec(82, &le32(&[1, -2_000_000_000, 0, 0, 0, 400, 0, 0])); + let mut name: Vec = "Arial".encode_utf16().flat_map(|u| u.to_le_bytes()).collect(); + name.resize(64, 0); + font.extend(name); + font[4..8].copy_from_slice(&104u32.to_le_bytes()); + let mut text = emf_rec(84, &le32(&[0, 0, 100, 100, 1, 0, 0, 5, 40, 4, 76, 0, 0, 0, 0, 0, 0])); + text.extend("WOWS".encode_utf16().flat_map(|u| u.to_le_bytes())); + text[4..8].copy_from_slice(&84u32.to_le_bytes()); + let mut emf = red_right_half_emf()[..88].to_vec(); + emf.extend([font, emf_rec(37, &le32(&[1])), emf_rec(24, &le32(&[0])), text, emf_rec(14, &[0; 12])].concat()); + let mut d = Document::new(); + let media = d.add_media(emf, "emf"); + let mut p = wordcraft_doc::Paragraph::new(); + let obj = wordcraft_doc::para::InlineObject::Image { + media, + w: 70.0, + h: 70.0, + alt: String::new(), + float: Default::default(), + crop: [0.0; 4], + ole: None, + }; + p.insert_object(0, obj, &Default::default()).unwrap(); + d.body = vec![wordcraft_doc::para_block(p)]; + let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); + let start = std::time::Instant::now(); + let img = render_page(&d, &l.pages[0], 1.0, &RenderOptions::default()); + // About 20 ms without the huge glyphs; drawing them ran past 4 minutes without finishing. + assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed()); + assert_eq!(img.width, 612); + // Leaving them out is logged (once a run, so it may have been by another test already). + assert!(OVERSIZED_GLYPHS_NOTED.load(std::sync::atomic::Ordering::Relaxed), "the left-out glyphs are logged"); + } + + /// Glyphs left out for their size are logged the first time only: a page is drawn on every + /// repaint. A run with none left out logs nothing and doesn't use up the warning. + #[test] + fn oversized_glyphs_are_logged_once_a_run() { + let noted = std::sync::atomic::AtomicBool::new(false); + assert!(!note_oversized_glyphs(¬ed, 0), "nothing left out, nothing logged"); + assert!(!noted.load(std::sync::atomic::Ordering::Relaxed)); + assert!(note_oversized_glyphs(¬ed, 4), "the first glyphs left out are logged"); + assert!(!note_oversized_glyphs(¬ed, 2), "later ones aren't"); + } + #[test] fn unreadable_metafile_keeps_the_grey_box() { let mut d = Document::new(); @@ -2487,6 +2662,34 @@ mod tests { assert_eq!(image.pixel(832, 800), [255, 255, 255, 255], "inside the first gap"); } + /// Dash lengths too small to cut the path make kurbo's dash iterator spin without moving along + /// it (and every piece is more stroke to build): such a pattern is drawn solid, as are negative + /// and non-finite ones. A normal dash pattern still dashes. + #[test] + fn hostile_dash_patterns_draw_solid_and_finish() { + use wordcraft_doc::graphic::PathSeg; + let line = |dash: Vec| Draw::Path { + segs: vec![PathSeg::Move(10.0, 20.0), PathSeg::Line(90.0, 20.0)], + fill: None, + stroke: Some(Rgb(0, 0, 0)), + stroke_width: 2.0, + dash, + cap: Default::default(), + }; + let start = std::time::Instant::now(); + for dash in [vec![1e-30, 1e-30], vec![1e-30], vec![-1.0, 2.0], vec![f32::NAN, 1.0], vec![f32::INFINITY, 1.0], vec![0.0, 0.0]] { + let img = render_items(&Document::new(), &[line(dash.clone())], 0.0, 0.0, 100.0, 40.0, 1.0, &RenderOptions::default()); + assert!(img.pixel(50, 20)[3] > 200, "{dash:?} draws solid"); + } + // Milliseconds drawn solid; drawn as dashes they ran past 4 minutes without finishing. + assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed()); + let dashed = render_items(&Document::new(), &[line(vec![10.0, 10.0])], 0.0, 0.0, 100.0, 40.0, 1.0, &RenderOptions::default()); + assert!(dashed.pixel(15, 20)[3] > 200 && dashed.pixel(25, 20)[3] == 0, "a normal pattern dashes"); + assert!(safe_dashes([3.0, 1.0], 1000.0).len() == 2); + assert!(safe_dashes([1e-3, 1e-3], 1000.0).is_empty(), "a million pieces"); + assert!(safe_dashes(std::iter::repeat_n(1.0, 200_000), 1.0).is_empty(), "too many entries"); + } + #[test] fn flowchart_or_spokes() { let r = kurbo::Rect::new(0.0, 0.0, 10.0, 10.0); diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 1a257045f9..e52538a375 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -9,12 +9,14 @@ description = "WordCraft egui front end (Word-style ribbon UI); swappable: nothi workspace = true [dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" } # Interface translations: lookup, fallback, plurals and system-locale matching over the craft-i18n # catalogs that `build.rs` embeds from `CRAFT_I18N_DIR` (storytold/craft-i18n, pinned in -# `craft-i18n.rev`). Its own rev, ahead of craft-ui's: lazy per-locale parsing +# `craft-i18n.rev`). Same craft-libs rev as craft-ui; it brings lazy per-locale parsing # (storytold/craft-libs#247), ICU4X plural rules and bidi isolates around inserted values. -craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" } +craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" } +# One native window per open document: the window list, the Window menu and the egui viewports. +craft-window = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", features = ["egui"] } wordcraft-geom = { workspace = true, features = ["image"] } wordcraft-doc = { workspace = true } wordcraft-fonts = { workspace = true } diff --git a/crates/ui-egui/src/a11y_pane.rs b/crates/ui-egui/src/a11y_pane.rs index cfc70fc258..ea46cb4448 100644 --- a/crates/ui-egui/src/a11y_pane.rs +++ b/crates/ui-egui/src/a11y_pane.rs @@ -10,10 +10,6 @@ use crate::theme::{regular, semibold}; /// Review › Check Accessibility: the findings for the document as it is now. pub fn accessibility(app: &mut WordApp, ui: &mut Ui) { - if crate::panes::header(ui, "Accessibility") { - let _ = app.run("view.accessibilityPane", json!({"value": false})); - return; - } let issues = issues(app, ui); if issues.is_empty() { ui.label(egui::RichText::new(tl!("No accessibility issues found.")).weak()); @@ -90,10 +86,6 @@ fn go_to(app: &mut WordApp, issue: &Value) { /// Alt Text: the selected picture's, shape's, chart's or group's description and decorative mark, /// or the title and description of the table the caret is in. pub fn alt_text(app: &mut WordApp, ui: &mut Ui) { - if crate::panes::header(ui, "Alt Text") { - let _ = app.run("view.altTextPane", json!({"value": false})); - return; - } if let Some((pos, obj)) = wordcraft_engine::cmd::objects::selected(&app.session) { let decorative = obj.is_decorative(); ui.label(egui::RichText::new(tl!("Describe what this object shows, for people who can't see it.")).weak()); diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index c2a99251fa..64f16b4bf5 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -329,7 +329,7 @@ fn home_page(app: &mut WordApp, ui: &mut Ui) { fn new_page(app: &mut WordApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); heading(ui, "New"); - let id = egui::Id::unique("backstage_template_search"); + let id = app.window_id("backstage_template_search"); let mut query = ui.ctx().data(|d| d.get_temp::(id)).unwrap_or_default(); let search = ui.add(egui::TextEdit::singleline(&mut query).hint_text(tl!("Search for templates")).desired_width(360.0)); if search.changed() { @@ -571,8 +571,8 @@ pub(crate) fn forget_save_as_error(ctx: &egui::Context) { /// Open a file from the Recent list. A file that can't be opened (deleted, moved, unreadable) /// leaves File open so the list can say why, rather than closing it on a greyed-out window. -fn open_recent(app: &mut WordApp, path: &str) -> Result<(), String> { - app.run("file.open", json!({"path": path}))?; +pub(crate) fn open_recent(app: &mut WordApp, path: &str) -> Result<(), String> { + app.open_file(path, None)?; app.ui.backstage = false; app.canvas.want_focus = true; Ok(()) @@ -1038,29 +1038,27 @@ mod tests { #[test] #[cfg(not(target_arch = "wasm32"))] - fn recovery_restore_uses_save_changes_and_resets_autosave_permission() { + fn recovery_restore_opens_beside_unsaved_work_without_autosave_permission() { let (app, root) = recovery_app(); let mut h = recovery_harness(app); settle_recovery(&mut h); h.state_mut().execute("text.insert", json!({"text": "Current unsaved work"})).unwrap(); + let work = h.state().session.document_id(); h.state_mut().autosave_path = Some(root.join("must-not-write.docx")); h.state_mut().run("file.recover", json!({})).unwrap(); h.run_steps(4); assert_eq!(h.state().session.recovery_documents().len(), 1); h.get_by_label("Restore").click(); - h.run_steps(4); - assert!(matches!(h.state().dialog, Some(crate::dialogs::Dialog::SaveChanges { .. }))); - h.get_by_label("Cancel").click(); - h.run_steps(4); - assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "Current unsaved work"); - h.get_by_label("Restore").click(); - h.run_steps(4); - h.get_by_label("Don't Save").click(); settle_recovery(&mut h); + assert!(h.state().dialog.is_none(), "nothing is replaced, so nothing to ask"); + assert_ne!(h.state().session.document_id(), work); assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "Recovered synthetic work"); assert!(h.state().session.dirty && h.state().session.path.is_none()); assert!(h.state().autosave_path.is_none()); assert!(!h.state().ui.backstage); + let kept = h.state_mut().session.with_document(work, |s| s.doc.plain_text(wordcraft_doc::StoryRef::Body)).unwrap(); + assert_eq!(kept, "Current unsaved work"); + assert!(h.state().session.recovery_documents().is_empty(), "the restored document keeps the copy"); assert!(!root.join("must-not-write.docx").exists()); drop(h); let _ = std::fs::remove_dir_all(root); @@ -1122,14 +1120,15 @@ mod tests { assert!(h.query_by_label("Report").is_some(), "the built-in templates stay"); // The search covers both. - h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique("backstage_template_search"), "memo".to_string())); + let search = h.state().window_id("backstage_template_search"); + h.ctx.data_mut(|d| d.insert_temp(search, "memo".to_string())); h.run_steps(3); assert!(h.query_by_label("Team memo").is_some(), "search finds a personal template"); assert!(h.query_by_label("Contract").is_none() && h.query_by_label("Report").is_none()); - h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique("backstage_template_search"), "zzz".to_string())); + h.ctx.data_mut(|d| d.insert_temp(search, "zzz".to_string())); h.run_steps(3); assert!(h.query_by_label("No templates match your search.").is_some()); - h.ctx.data_mut(|d| d.insert_temp(egui::Id::unique("backstage_template_search"), String::new())); + h.ctx.data_mut(|d| d.insert_temp(search, String::new())); h.run_steps(3); h.get_by_label("Team memo").click(); @@ -1174,33 +1173,25 @@ mod tests { let _ = std::fs::remove_dir_all(&chosen); } - /// A personal template replaces the document like New does: unsaved changes are asked about - /// first, and nothing is replaced until that is answered. + /// A personal template opens a new document like New does: nothing is asked, and the + /// document with unsaved changes stays open beside it. #[test] - fn a_personal_template_asks_about_unsaved_changes_first() { + fn a_personal_template_opens_beside_unsaved_work() { let dir = personal_dir("dirty"); let memo = dir.join("Memo.dotx"); write_template(&memo, "From the template"); let mut h = new_page_harness(Some(dir.clone())); h.state_mut().run("document.setText", json!({"text": "Unsaved work"})).unwrap(); - assert!(h.state().session.dirty); + let first = h.state().session.document_id(); h.get_by_label("Memo").click(); h.run_steps(3); let body = |h: &egui_kittest::Harness<'static, WordApp>| h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body); - assert!(matches!(h.state().dialog, Some(crate::dialogs::Dialog::SaveChanges { .. })), "Save changes is asked"); - assert_eq!(body(&h), "Unsaved work", "the document stays until the question is answered"); - h.state_mut().run("ui.saveChanges", json!({"answer": "cancel"})).unwrap(); - h.run_steps(2); - assert_eq!(body(&h), "Unsaved work", "Cancel keeps it"); - assert_eq!(h.state().session.doc.settings.attached_template, None); - h.state_mut().run("ui.backstage", json!({"value": true, "page": "new"})).unwrap(); - h.run_steps(3); - h.get_by_label("Memo").click(); - h.run_steps(3); - h.state_mut().run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); - h.run_steps(2); - assert!(body(&h).contains("From the template"), "Don't Save makes the document from the template"); + assert!(h.state().dialog.is_none(), "nothing is asked"); + assert!(body(&h).contains("From the template")); assert_eq!(h.state().session.doc.settings.attached_template.as_deref(), Some(memo.to_string_lossy().as_ref())); + h.state_mut().run("view.switchWindows", json!({"id": first})).unwrap(); + assert_eq!(body(&h), "Unsaved work", "the unsaved document is still open"); + assert!(h.state().session.dirty); drop(h); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a13095a2a9..5ccb066042 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -142,8 +142,9 @@ pub struct CanvasState { /// The scroll offset and its maximum at the end of last frame. pub(crate) scroll_offset: egui::Vec2, scroll_max: egui::Vec2, - /// Explicit page turn to apply on the next frame (reading navigation buttons/keys). - scroll_request: Option, + /// Explicit scroll position to apply on the next frame (reading navigation buttons/keys, a + /// window moving into another viewport). + pub(crate) scroll_request: Option, /// The viewport (the content's visible rect on screen) at the end of last frame; the /// drag-select edge auto-scroll decides this frame's scroll from it (#688). pub(crate) viewport: Option, @@ -231,6 +232,32 @@ pub struct CanvasState { type TablePreviewKey = ((usize, usize, u64, u64, wordcraft_engine::Selection), std::sync::Arc); impl CanvasState { + /// Another document is shown in the window: everything about the page that was shown (drags, + /// menus, the input method's composition, a scroll in progress, rendered pages…) starts + /// afresh. What belongs to the window or the user stays: the colours last applied, the + /// ruler's tab kind, the faces added for interface text, the page area's size. The scroll + /// position comes back by itself, as the page's scroll area keeps one per document in egui's + /// memory. `seen`: the document was shown before, so the view stays where it was rather than + /// going to the caret. + pub(crate) fn show_other_document(&mut self, seen: bool) { + let old = std::mem::take(self); + let ime_reset = !old.ime_preedit.is_empty() || old.ime_preedit_range.is_some(); + *self = CanvasState { + last_highlight: old.last_highlight, + last_font_color: old.last_font_color, + last_shading: old.last_shading, + ime_faces: old.ime_faces, + tab_kind: old.tab_kind, + scale: old.scale, + canvas_rect: old.canvas_rect, + read_viewport: old.read_viewport, + web_viewport: old.web_viewport, + scroll_to_caret: !seen, + ime_reset, + ..CanvasState::default() + }; + } + /// Stop a text-selection drag when the pointer gesture becomes an object drag. pub(crate) fn cancel_text_drag(&mut self) { self.dragging = false; @@ -1282,7 +1309,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { }; let delta = wheel_delta + auto_delta + middle_delta; let mut scroll_area = egui::ScrollArea::both() - .id_salt("canvas_scroll") + // One scroll position per open document, so switching back finds the page as it was left. + .id_salt(("canvas_scroll", app.session.document_id())) .auto_shrink([false, false]) // Bringing the caret into view glides there, or jumps with animation effects off. .animated(animate) @@ -5755,17 +5783,17 @@ mod tests { assert!(matches!(h.state().dialog, Some(crate::dialogs::Dialog::SaveChanges { .. }))); } - /// #1348: an agent can't replace the document while comment text is still being typed - /// unless it passes `force`, as with unsaved changes. + /// #1348: an agent can't close the document while comment text is still being typed unless + /// it passes `force`, as with unsaved changes. New and Open open beside it, losing nothing. #[test] - fn an_agent_cannot_replace_the_document_over_a_pending_comment_draft() { + fn an_agent_cannot_close_the_document_over_a_pending_comment_draft() { let (mut h, id) = comment_harness(); h.state_mut().session.dirty = false; click_balloon(&mut h, id); type_in(&mut h, "Typed "); let open = json!({"path": "/nonexistent/other.docx"}); - assert!(crate::control::close_refused(h.state(), "file.open", &open).is_some()); - assert!(crate::control::close_refused(h.state(), "file.new", &json!({})).is_some()); + assert!(crate::control::close_refused(h.state(), "file.open", &open).is_none()); + assert!(crate::control::close_refused(h.state(), "file.new", &json!({})).is_none()); assert!(crate::control::close_refused(h.state(), "file.close", &json!({})).is_some()); assert!(crate::control::close_refused(h.state(), "file.close", &json!({"force": true})).is_none()); assert!(crate::control::close_refused(h.state(), "text.insert", &json!({"text": "x"})).is_none()); @@ -5792,15 +5820,18 @@ mod tests { assert!(h.state_mut().close_requested(), "Don't Save closes"); } - /// #1348: New with a refused draft asks to save; Don't Save makes the new document. + /// #1348: New with a refused draft opens beside it, asking nothing; the draft waits with its + /// document and is there when that document is shown again. #[test] - fn a_refused_comment_draft_can_be_dropped_on_new() { + fn a_refused_comment_draft_stays_with_its_document_on_new() { let (mut h, _) = refused_draft(); let document = h.state().session.document_id(); h.state_mut().run("file.new", json!({})).unwrap(); - assert!(matches!(h.state().dialog, Some(crate::dialogs::Dialog::SaveChanges { .. })), "the save prompt asks about it"); - h.state_mut().run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); - assert_ne!(h.state().session.document_id(), document, "Don't Save made the new document"); + assert!(h.state().dialog.is_none(), "nothing is asked"); + assert_ne!(h.state().session.document_id(), document, "New made a document"); + assert!(!crate::panes::has_comment_drafts(h.state()), "the draft isn't the new document's"); + h.state_mut().run("view.switchWindows", json!({"id": document})).unwrap(); + assert!(crate::panes::has_comment_drafts(h.state()), "the draft is back with its document"); } /// #1348: Save with a refused draft saves the rest of the document, keeps the draft and says why. diff --git a/crates/ui-egui/src/content_controls.rs b/crates/ui-egui/src/content_controls.rs index 923be1d215..b482244590 100644 --- a/crates/ui-egui/src/content_controls.rs +++ b/crates/ui-egui/src/content_controls.rs @@ -25,6 +25,13 @@ pub struct ControlUi { popup: Option, } +impl ControlUi { + /// Whether a control's list or calendar is open. + pub(crate) fn popup_open(&self) -> bool { + self.popup.is_some() + } +} + struct Popup { /// Content start of the control it belongs to. at: Pos, diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index ac85e3f88b..cc8f66bb4e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1,7 +1,7 @@ //! Programmatic control of the running app (agents, tests, MCP bridge). //! //! Methods (JSON lines over the host's transport, see `docs/control-protocol.md`): -//! - `engine.execute {command, params}`: run any command (engine or `ui.*`) +//! - `engine.execute {command, params, doc?}`: run any command (engine or `ui.*`) //! - `engine.commands {search?}`: engine and app print commands with enablement and parameters //! - `document.inspect`: document structure; `ui.inspect`: UI state, canvas geometry, perf //! - `ui.wheel {x, y, dx?, dy, unit?, phase?}`: a mouse-wheel / trackpad scroll at a window point. @@ -12,6 +12,10 @@ //! - `ui.screenshot {path?}`: PNG of the window; `ui.render {path?, page?, scale?}`: page PNG //! (without `path`, both reply with the PNG as base64 `png`) //! - `ui.parity`: feature catalog parity +//! +//! `engine.execute`, `document.inspect` and `ui.render` take `doc`, the id of an open document +//! (`view.switchWindows` lists them): they work on that document while the window keeps showing +//! the active one. Without `doc` they work on the active document. use std::sync::mpsc::Sender; @@ -47,7 +51,7 @@ fn err(e: impl std::fmt::Display) -> Outcome { } pub fn inspect(app: &mut WordApp, ctx: &egui::Context) -> Value { - let r = ctx.content_rect(); + let r = ctx.input_for(app.active_viewport(), |i| i.content_rect()); let caret = crate::canvas::caret_screen(app).map(|(p, h)| json!({"x": p.x, "y": p.y, "h": h})); json!({ "ui": app.prefs(), @@ -64,6 +68,8 @@ pub fn inspect(app: &mut WordApp, ctx: &egui::Context) -> Value { "focused": app.canvas.focused, "title": app.title_stem(), "dirty": app.session.dirty, + "document": app.session.document_id(), + "documents": app.session.documents(), "perf": {"frameMs": app.frame_ms, "renderMs": app.canvas.render_ms, "layoutMs": app.session.layout().ms}, }) } @@ -116,21 +122,102 @@ pub(crate) fn close_refused(app: &WordApp, id: &str, params: &Value) -> Option<& return None; } if id == "file.close" && app.session.dirty { - return Some("the document has unsaved changes: save it first, or pass {\"force\": true} to discard them and close"); + return Some(UNSAVED); } // Comment text still being typed is unsaved work too (#1348): replacing the document would drop it. (crate::discards_document(id, params) && crate::panes::has_comment_drafts(app)) .then_some("a comment is still being edited: finish it first, or pass {\"force\": true} to discard it") } +const UNSAVED: &str = "the document has unsaved changes: save it first, or pass {\"force\": true} to discard them and close"; + /// The reply to a request that needs a document while none is open (#1129). const NO_DOCUMENT: &str = "no document is open; create one (file.new) or open one (file.open) first"; +/// The methods that take `doc`. +const DOC_METHODS: &[&str] = &["engine.execute", "command", "document.inspect", "ui.render"]; + +/// The open document other than the active one that a request's `doc` names; `None` for the +/// active document (no `doc`, null, or the active document's id). An id that names no open +/// document is an error. +fn addressed(app: &WordApp, p: &Value) -> Result, String> { + let id = match p.get("doc") { + None | Some(Value::Null) => return Ok(None), + Some(v) => v.as_u64().ok_or("`doc` must be the id of an open document (view.switchWindows lists them)")?, + }; + if !app.session.is_open(id) { + return Err(format!("no open document has id {id}")); + } + Ok((id != app.session.document_id()).then_some(id)) +} + +/// The reply to work run on another document: its result, or why it couldn't run or failed. +fn reply(r: Result, F>) -> Outcome { + match r { + Ok(Ok(v)) => ok(v), + Ok(Err(e)) => err(e), + Err(e) => err(e), + } +} + +/// A document method addressed to open document `doc`, which isn't the active one: it runs on +/// that document, and the window keeps showing the active one. +fn handle_in(app: &mut WordApp, doc: u64, method: &str, p: &Value) -> Outcome { + if app.no_document == Some(doc) { + return err(NO_DOCUMENT); + } + match method { + "document.inspect" => reply(app.session.with_document(doc, |s| s.run("document.inspect", p))), + "ui.render" => { + let page = p.get("page").and_then(Value::as_u64).unwrap_or(1); + let scale = p.get("scale").and_then(Value::as_f64).map_or(2.0, |v| v as f32); + let path = p.get("path").and_then(Value::as_str); + let r = app.session.with_document(doc, |s| match path { + Some(path) => s.run("file.exportPng", &json!({"path": path, "page": page, "scale": scale})).map_err(|e| e.to_string()), + None => render_png(s, page, scale), + }); + reply(r) + } + _ => { + let Some(id) = p.get("command").or(p.get("id")).and_then(Value::as_str) else { return err("missing `command`") }; + let params = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({})); + if id.starts_with("ui.") { + return err(format!("`{id}` works on the window: make the document active first (view.switchWindows {{\"id\": {doc}}})")); + } + // Close, with the same guard as the window's: unsaved changes need `force`. + if id == "file.close" { + let force = params.get("force").and_then(Value::as_bool) == Some(true); + if !force && app.session.documents().iter().any(|d| d.id == doc && d.dirty) { + return err(UNSAVED); + } + return match app.session.close_document(doc) { + Ok(()) => ok(json!({"closed": true, "id": doc, "documents": app.session.documents()})), + Err(e) => err(e), + }; + } + let r = app.session.with_document(doc, |s| s.run(id, ¶ms)); + // UI requests the command made carry `document`, so the window can tell they aren't + // about the document it shows. + for req in std::mem::take(&mut app.session.ui_requests) { + app.handle_request(&req); + } + reply(r) + } + } +} + /// Dispatches one JSON-lines control request: commands and inspection, parity, synthetic click, /// drag, key, wheel and text input, screenshots and renders, resize, focus and quit, and unknown /// ids as commands. It refuses closing unsaved work and document operations without a document. pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; + if DOC_METHODS.contains(&req.method.as_str()) { + match addressed(app, p) { + Ok(Some(doc)) => return handle_in(app, doc, &req.method, p), + Ok(None) => {} + Err(e) => return err(e), + } + } let s = |k: &str| p.get(k).and_then(Value::as_str); let f = |k: &str| p.get(k).and_then(Value::as_f64).map(|v| v as f32); match req.method.as_str() { @@ -269,7 +356,7 @@ pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> O }, // No path: the PNG travels in the reply. Never a fixed name in the shared // temporary folder, where another user could have planted a link to a file. - None => match render_png(app, page, scale) { + None => match render_png(&app.session, page, scale) { Ok(v) => ok(v), Err(e) => err(e), }, @@ -282,11 +369,11 @@ pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> O let Some(size) = crate::window_geometry::requested_size(egui::vec2(w, h), max_side) else { return err("width and height must be positive numbers no larger than 3.4e38"); }; - ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size)); + ctx.send_viewport_cmd_to(app.active_viewport(), egui::ViewportCommand::InnerSize(size)); ok(json!({"width": size.x, "height": size.y})) } "ui.focus" => { - ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + ctx.send_viewport_cmd_to(app.active_viewport(), egui::ViewportCommand::Focus); app.canvas.want_focus = true; ok(json!({})) } @@ -309,11 +396,11 @@ pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> O /// A page rendered by the engine, as base64 PNG in the reply (the same shape as the headless /// MCP server's `ui.render`). -fn render_png(app: &WordApp, page: u64, scale: f32) -> Result { +fn render_png(session: &wordcraft_engine::Session, page: u64, scale: f32) -> Result { let page = usize::try_from(page.max(1)).unwrap_or(usize::MAX).saturating_sub(1); - let l = app.session.export_layout(); - let pg = l.pages.get(page).ok_or_else(|| format!("no page {}", page + 1))?; - let img = wordcraft_render::render_page(&app.session.doc, pg, scale.clamp(0.1, 8.0), &Default::default()); + let l = session.export_layout(); + let pg = l.pages.get(page).ok_or_else(|| format!("no page {}", page.saturating_add(1)))?; + let img = wordcraft_render::render_page(&session.doc, pg, scale.clamp(0.1, 8.0), &Default::default()); let png = img.to_png(); Ok(json!({"png": wordcraft_engine::cmd::insert::base64_encode(&png), "width": img.width, "height": img.height, "pages": l.pages.len()})) } @@ -370,20 +457,29 @@ impl SurfaceGeometry { /// Derives this frame's capturable surface from raw viewport data, rejecting a missing, non-finite /// or zero-sized visible surface and recording minimized or occluded state. pub(crate) fn from_raw(raw: &egui::RawInput, ctx: &egui::Context) -> Option { - let viewport = raw.viewports.get(&raw.viewport_id)?; + Self::from_viewport(raw.viewports.get(&raw.viewport_id)?, raw.screen_rect, raw.max_texture_side, ctx) + } + + /// [`Self::from_raw`] for one viewport's info, content rectangle and texture limit. + pub(crate) fn from_viewport( + viewport: &egui::ViewportInfo, + screen_rect: Option, + max_texture_side: Option, + ctx: &egui::Context, + ) -> Option { let minimized = viewport.minimized == Some(true); let occluded = viewport.occluded == Some(true); // A minimized/occluded surface may report no rectangle at all. Its stale size is harmless // because such a surface is never capturable; a visible surface without a rectangle is not // fresh enough to use. - let rect = match viewport.inner_rect.or(raw.screen_rect) { + let rect = match viewport.inner_rect.or(screen_rect) { Some(rect) => rect, None if minimized || occluded => ctx.content_rect(), None => return None, }; let zoom = ctx.zoom_factor(); let pixels_per_point = viewport.native_pixels_per_point.map_or_else(|| ctx.pixels_per_point(), |native| native * zoom); - let max_texture_side = raw.max_texture_side.filter(|side| *side > 0).unwrap_or_else(|| ctx.input(|i| i.max_texture_side)); + let max_texture_side = max_texture_side.filter(|side| *side > 0).unwrap_or_else(|| ctx.input(|i| i.max_texture_side)); if !rect.width().is_finite() || !rect.height().is_finite() || rect.width() <= 0.0 || rect.height() <= 0.0 { return None; } @@ -393,9 +489,10 @@ impl SurfaceGeometry { Some(Self { viewport: rect.size(), pixels_per_point, max_texture_side, minimized, occluded }) } - /// Pairs the surface geometry with the document id a capture belongs to. - pub(crate) fn with_document(self, document: u64) -> CaptureGeometry { - CaptureGeometry { surface: self, document } + /// Pairs the surface geometry with the window's viewport and the document id a capture + /// belongs to. + pub(crate) fn with_target(self, viewport: egui::ViewportId, document: u64) -> CaptureGeometry { + CaptureGeometry { surface: self, viewport, document } } } @@ -405,6 +502,8 @@ impl SurfaceGeometry { #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct CaptureGeometry { surface: SurfaceGeometry, + /// The active window's viewport, which the capture command goes to. + viewport: egui::ViewportId, document: u64, } @@ -414,6 +513,18 @@ impl CaptureGeometry { self.document } + /// Why a capture admitted for `viewport` and `document` can't go on against this geometry: + /// another window was brought forward, or the window shows another document. + fn moved_reason(self, viewport: egui::ViewportId, document: u64) -> Option<&'static str> { + if self.viewport != viewport { + Some("screenshot failed: another window was brought forward while the capture was queued") + } else if self.document != document { + Some("screenshot failed: the document changed while the capture was queued") + } else { + None + } + } + /// Explains why the surface cannot be captured, when it is minimized or fully occluded, and /// returns `None` when it can. pub(crate) fn unavailable_reason(self) -> Option<&'static str> { @@ -483,6 +594,9 @@ pub(crate) struct Shot { pub(crate) retries: u32, /// The document the request was admitted for. It never changes; a document switch cancels. pub(crate) document: u64, + /// The viewport of the window the request was admitted for. It never changes; bringing + /// another window forward cancels. + pub(crate) viewport: egui::ViewportId, pub(crate) geometry: CaptureGeometry, pub(crate) epoch: u64, pub(crate) expected: [usize; 2], @@ -537,6 +651,8 @@ fn next_token(app: &mut WordApp) -> u64 { /// earlier request. pub(crate) fn admit_screenshot(app: &mut WordApp, ctx: &egui::Context, path: Option, reply: Sender) { let token = next_token(app); + // An earlier request in this batch may have brought another window forward. + app.refresh_capture_geometry(ctx); let Some(geometry) = app.capture_geometry.map(|g| CaptureGeometry { document: app.session.document_id(), ..g }) else { reject_shot(reply, token, "screenshot failed: the window geometry is not known yet"); return; @@ -598,6 +714,7 @@ pub(crate) fn admit_screenshot(app: &mut WordApp, ctx: &egui::Context, path: Opt issued: false, retries: 0, document: geometry.document(), + viewport: geometry.viewport, geometry, epoch: app.capture_epoch, expected, @@ -622,7 +739,8 @@ pub(crate) fn issue_screenshots(app: &mut WordApp, ctx: &egui::Context) { let shot = &app.shots.requests[i]; (shot.issued, shot.ready_at, shot.frames) }; - if issued { + // Each window issues its own captures, from a pass that drew its layout. + if issued || app.shots.requests[i].viewport != ctx.viewport_id() { continue; } let frames = if first_pass { frames.saturating_add(1) } else { frames }; @@ -638,8 +756,8 @@ pub(crate) fn issue_screenshots(app: &mut WordApp, ctx: &egui::Context) { failures.push((i, format!("screenshot failed: {reason} while the capture was queued"))); continue; } - if current.document() != app.shots.requests[i].document { - failures.push((i, "screenshot failed: the document changed while the capture was queued".into())); + if let Some(reason) = current.moved_reason(app.shots.requests[i].viewport, app.shots.requests[i].document) { + failures.push((i, reason.into())); continue; } let Some(expected) = current.expected_size() else { @@ -676,7 +794,7 @@ pub(crate) fn issue_screenshots(app: &mut WordApp, ctx: &egui::Context) { shot.bytes = bytes; shot.epoch = epoch; if let Some(callback) = shot.callback.take() { - ctx.send_viewport_cmd_to(shot.viewport(), egui::ViewportCommand::Screenshot(callback)); + ctx.send_viewport_cmd_to(shot.viewport, egui::ViewportCommand::Screenshot(callback)); shot.issued = true; } } @@ -692,13 +810,6 @@ pub(crate) fn issue_screenshots(app: &mut WordApp, ctx: &egui::Context) { } } -impl Shot { - /// The screenshot callback's viewport: the root window. - fn viewport(&self) -> egui::ViewportId { - egui::ViewportId::ROOT - } -} - /// A completed image is either stale because the surface changed (safe to retry once within the /// deadline) or invalid for this request (report it at once; retrying cannot help). enum ImageCheck { @@ -764,6 +875,13 @@ fn take_screenshot_action(app: &mut WordApp, index: usize, now: f64, current: Op } return ShotAction::Remove(error_reply(shot.token, "no frame was presented before the screenshot timeout")); } + // Before the issue check: a window that closed never draws to issue its capture. + if let Some(reason) = current.and_then(|geometry| geometry.moved_reason(shot.viewport, shot.document)) { + if let Some(slot) = shot.slot.take() { + slot.cancel(); + } + return ShotAction::Remove(error_reply(shot.token, reason)); + } if !shot.issued { return ShotAction::Keep; } @@ -773,12 +891,6 @@ fn take_screenshot_action(app: &mut WordApp, index: usize, now: f64, current: Op } return ShotAction::Remove(error_reply(shot.token, format!("screenshot failed: {reason} while the capture was pending"))); } - if current.is_some_and(|geometry| geometry.document() != shot.document) { - if let Some(slot) = shot.slot.take() { - slot.cancel(); - } - return ShotAction::Remove(error_reply(shot.token, "screenshot failed: the document changed while the capture was queued")); - } shot.slot.as_ref().and_then(|slot| slot.take()) }; match completed { @@ -994,7 +1106,7 @@ mod tests { /// Builds a capture geometry fixture for document id 7. fn geometry(viewport: egui::Vec2, pixels_per_point: f32, max_texture_side: usize) -> CaptureGeometry { - CaptureGeometry { surface: surface(viewport, pixels_per_point, max_texture_side), document: 7 } + CaptureGeometry { surface: surface(viewport, pixels_per_point, max_texture_side), viewport: egui::ViewportId::ROOT, document: 7 } } /// Creates a black test image of the given size. @@ -1119,3 +1231,7 @@ mod screenshot_tests { assert!(!outside.exists()); } } + +#[cfg(test)] +#[path = "control_tests.rs"] +mod doc_tests; diff --git a/crates/ui-egui/src/control_tests.rs b/crates/ui-egui/src/control_tests.rs new file mode 100644 index 0000000000..fe0a0f3637 --- /dev/null +++ b/crates/ui-egui/src/control_tests.rs @@ -0,0 +1,122 @@ +//! The control channel's `doc`: methods addressed to an open document that isn't the active one. + +use serde_json::{Value, json}; +use wordcraft_engine::Session; + +use super::{ControlRequest, Outcome, handle}; +use crate::{Services, WordApp}; + +fn app() -> WordApp { + WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()) +} + +/// Send one request the way the control channel does and return the reply. +fn send(a: &mut WordApp, method: &str, params: Value) -> Value { + let ctx = egui::Context::default(); + let (req, _reply) = ControlRequest::new(method, params); + match handle(a, &ctx, &req) { + Outcome::Done(v) => v, + Outcome::Screenshot { .. } => json!({"ok": false, "error": "not a reply"}), + } +} + +fn exec(a: &mut WordApp, command: &str, params: Value) -> Value { + send(a, "engine.execute", json!({"command": command, "params": params})) +} + +fn body(a: &WordApp) -> String { + a.session.doc.plain_text(wordcraft_doc::StoryRef::Body) +} + +/// Two documents, the first typed in and parked behind the second: (first, second). +fn two_documents(a: &mut WordApp) -> (u64, u64) { + let first = a.session.document_id(); + assert_eq!(exec(a, "text.insert", json!({"text": "First"}))["ok"], true); + assert_eq!(exec(a, "file.new", json!({}))["ok"], true); + let second = a.session.document_id(); + assert_ne!(first, second); + assert_eq!(exec(a, "text.insert", json!({"text": "Second"}))["ok"], true); + (first, second) +} + +/// W1: `doc` sends a command, an inspection or a render to a document in the background; the +/// window's document stays active and unchanged, and an id that names no open document is an +/// error. +#[test] +fn methods_address_an_open_document_by_id() { + let mut a = app(); + let (first, second) = two_documents(&mut a); + + let r = send(&mut a, "engine.execute", json!({"command": "text.insert", "params": {"text": " more"}, "doc": first})); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(a.session.document_id(), second); + assert_eq!(body(&a).trim(), "Second"); + let r = send(&mut a, "document.inspect", json!({"doc": first})); + assert_eq!(r["result"]["blocks"][0]["text"], "First more", "{r}"); + let r = send(&mut a, "ui.render", json!({"doc": first, "scale": 0.2})); + assert!(r["result"]["png"].as_str().is_some_and(|p| !p.is_empty()), "{r}"); + // The active document's own id works like no `doc` at all. + let r = send(&mut a, "engine.execute", json!({"command": "text.insert", "params": {"text": "!"}, "doc": second})); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(body(&a).trim(), "Second!"); + + let r = send(&mut a, "ui.inspect", json!({})); + assert_eq!(r["result"]["document"], second, "{r}"); + let ids: Vec = r["result"]["documents"].as_array().into_iter().flatten().filter_map(|d| d["id"].as_u64()).collect(); + assert_eq!(ids, vec![first, second]); + + // Window commands need the document on screen. + let r = send(&mut a, "engine.execute", json!({"command": "ui.tab", "params": {"value": "insert"}, "doc": first})); + assert_eq!(r["ok"], false, "{r}"); + + for bad in [json!(u64::MAX), json!(-1), json!("1"), json!(first.to_string()), json!(1.5), json!(1e300), json!({}), json!([first])] { + for (method, params) in [ + ("engine.execute", json!({"command": "text.insert", "params": {"text": "x"}, "doc": bad})), + ("document.inspect", json!({"doc": bad})), + ("ui.render", json!({"doc": bad})), + ] { + let r = send(&mut a, method, params); + assert_eq!(r["ok"], false, "{method} with doc {bad}: {r}"); + } + } + assert_eq!(a.session.document_id(), second); + assert_eq!(body(&a).trim(), "Second!"); + let r = send(&mut a, "document.inspect", json!({"doc": first})); + assert_eq!(r["result"]["blocks"][0]["text"], "First more", "{r}"); +} + +/// W1: `file.close` addressed to a document in the background refuses its unsaved changes +/// unless forced (only a boolean true), as it does for the window's document (#1268), and closes +/// only that document. +#[test] +fn closing_an_addressed_document_keeps_unsaved_work_unless_forced() { + let mut a = app(); + let (first, second) = two_documents(&mut a); + + for params in [json!({}), json!({"force": "true"}), json!({"force": 1})] { + let r = send(&mut a, "engine.execute", json!({"command": "file.close", "params": params, "doc": first})); + assert_eq!(r["ok"], false, "{r}"); + let e = r["error"].as_str().unwrap_or(""); + assert!(e.contains("unsaved") && e.contains("force"), "{e}"); + assert!(a.session.is_open(first)); + } + + let r = send(&mut a, "engine.execute", json!({"command": "file.close", "params": {"force": true}, "doc": first})); + assert_eq!(r["ok"], true, "{r}"); + assert!(!a.session.is_open(first)); + assert_eq!(a.session.document_id(), second); + assert!(a.has_document() && !a.quit_requested); + assert_eq!(body(&a).trim(), "Second"); + let r = send(&mut a, "engine.execute", json!({"command": "text.insert", "params": {"text": "x"}, "doc": first})); + assert_eq!(r["ok"], false, "a closed document can't be addressed: {r}"); + + // A document without unsaved changes closes at once. + assert_eq!(exec(&mut a, "file.new", json!({}))["ok"], true); + let clean = a.session.document_id(); + assert_eq!(exec(&mut a, "file.new", json!({}))["ok"], true); + let active = a.session.document_id(); + let r = send(&mut a, "engine.execute", json!({"command": "file.close", "doc": clean})); + assert_eq!(r["ok"], true, "{r}"); + assert!(!a.session.is_open(clean) && a.session.is_open(second)); + assert_eq!(a.session.document_id(), active); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index eb82bdf312..a464e03e7f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4092,7 +4092,8 @@ const SEARCH_SUGGESTIONS: [&str; 3] = ["review.spelling", "review.wordCount", "f /// Search's "Find … in the document": the Navigation pane opens on its results for `text`. fn find_in_navigation_pane(app: &mut WordApp, ctx: &egui::Context, text: &str) { - ctx.data_mut(|d| d.insert_temp(egui::Id::unique("nav_query"), text.to_string())); + let qid = app.window_id("nav_query"); + ctx.data_mut(|d| d.insert_temp(qid, text.to_string())); app.ui.nav_tab = "results".into(); let _ = app.run("view.navigationPane", json!({"value": true})); let _ = app.run("edit.find", json!({"text": text})); @@ -4792,15 +4793,16 @@ mod tests { /// #1346: a check box the selected paragraphs differ in shows as neither on nor off; a click /// and OK turn it on in all of them, and the other fields they differ in stay as they were. - /// #3028: the records dialog refuses a range with no records itself, before Save Changes asks - /// about the unsaved document; a good range goes on to that question. + /// #3028: the records dialog refuses a range with no records itself; a good range merges into + /// a new document beside the unsaved one, asking nothing. #[test] - fn merge_records_dialog_checks_the_range_before_asking_to_save() { + fn merge_records_dialog_checks_the_range_before_merging() { use egui_kittest::kittest::Queryable; let mut app = app_with("Dear "); let _ = app.run("mailings.recipients", json!({"csv": "Name\nAda\nAlan\nGrace"})); let _ = app.run("text.insert", json!({"text": "unsaved"})); assert!(app.session.dirty); + let source = app.session.document_id(); app.dialog = Dialog::open("mergeRecords", &mut app); if let Some(Dialog::MergeRecords { choice, from, to, .. }) = &mut app.dialog { *choice = 2; @@ -4836,8 +4838,10 @@ mod tests { for _ in 0..2 { h.step(); } - assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges")); - assert!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("unsaved"), "nothing merged yet"); + assert!(h.state().dialog.is_none(), "nothing to ask about: the merge opens beside"); + assert_ne!(h.state().session.document_id(), source, "the merge is a new document"); + h.state_mut().run("view.switchWindows", json!({"id": source})).unwrap(); + assert!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("unsaved"), "the unsaved document is still open"); } #[test] diff --git a/crates/ui-egui/src/dialogs_review.rs b/crates/ui-egui/src/dialogs_review.rs index e14d29f092..b1f38ccb9e 100644 --- a/crates/ui-egui/src/dialogs_review.rs +++ b/crates/ui-egui/src/dialogs_review.rs @@ -621,7 +621,7 @@ fn setup_ui(app: &mut WordApp, ui: &mut Ui, st: &mut RestrictPane) { if st.formatting && st.show_styles { let allowed = st.allowed.get_or_insert_with(Vec::new); ui.label(egui::RichText::new(tl!("Checked styles are allowed:")).small()); - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { if ui.small_button(tl!("All")).clicked() { *allowed = styles.iter().map(|(id, _)| id.clone()).collect(); } @@ -643,14 +643,16 @@ fn setup_ui(app: &mut WordApp, ui: &mut Ui, st: &mut RestrictPane) { }); } heading(ui, "2. Editing restrictions"); - ui.checkbox(&mut st.editing, tl!("Only allow this kind of editing:")); + let editing = ui.checkbox(&mut st.editing, tl!("Only allow this kind of editing:")); ui.add_enabled_ui(st.editing, |ui| { let current = EDIT_MODES.get(st.mode).map(|m| m.1).unwrap_or(""); - egui::ComboBox::from_id_salt("restrict_mode").selected_text(tl!(current)).width(220.0).show_ui(ui, |ui| { - for (i, (_, label)) in EDIT_MODES.iter().enumerate() { - ui.selectable_value(&mut st.mode, i, tl!(label)); - } - }); + let combo = + egui::ComboBox::from_id_salt("restrict_mode").selected_text(tl!(current)).width(ui.available_width().min(220.0)).show_ui(ui, |ui| { + for (i, (_, label)) in EDIT_MODES.iter().enumerate() { + ui.selectable_value(&mut st.mode, i, tl!(label)); + } + }); + combo.response.labelled_by(editing.id); }); let mode = EDIT_MODES.get(st.mode).map(|m| m.0).unwrap_or("readOnly"); if st.editing && matches!(mode, "readOnly" | "comments") { @@ -677,7 +679,7 @@ fn setup_ui(app: &mut WordApp, ui: &mut Ui, st: &mut RestrictPane) { } else { ui.label(egui::RichText::new(tl!("The password is optional; without it anyone can stop the protection.")).small()); passwords(ui, st, true); - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { // macOS puts Cancel before OK. let mac = cfg!(target_os = "macos"); let mut cancel = mac && ui.button(tl!("Cancel")).clicked(); @@ -715,17 +717,15 @@ fn setup_ui(app: &mut WordApp, ui: &mut Ui, st: &mut RestrictPane) { error(ui, &st.message); } +/// Each label above its field, so both fit the narrowest pane. fn passwords(ui: &mut Ui, st: &mut RestrictPane, confirm: bool) { - egui::Grid::new("restrict_pw").num_columns(2).spacing(vec2(8.0, 4.0)).show(ui, |ui| { - ui.label(tl!("Password:")); - ui.add(egui::TextEdit::singleline(st.password.as_mut_string()).password(true).desired_width(140.0)); - ui.end_row(); - if confirm { - ui.label(tl!("Confirm password:")); - ui.add(egui::TextEdit::singleline(st.confirm.as_mut_string()).password(true).desired_width(140.0)); - ui.end_row(); - } - }); + let width = ui.available_width().min(220.0); + let label = ui.label(tl!("Password:")); + ui.add(egui::TextEdit::singleline(st.password.as_mut_string()).password(true).desired_width(width)).labelled_by(label.id); + if confirm { + let label = ui.label(tl!("Confirm password:")); + ui.add(egui::TextEdit::singleline(st.confirm.as_mut_string()).password(true).desired_width(width)).labelled_by(label.id); + } } fn enforced_ui(app: &mut WordApp, ui: &mut Ui, st: &mut RestrictPane, mode: &str) { @@ -765,7 +765,7 @@ fn enforced_ui(app: &mut WordApp, ui: &mut Ui, st: &mut RestrictPane, mode: &str } else { ui.label(egui::RichText::new(tl!("Type the password to stop the protection.")).small()); passwords(ui, st, false); - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { // macOS puts Cancel before OK. let mac = cfg!(target_os = "macos"); let cancel_first = mac && ui.button(tl!("Cancel")).clicked(); diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs new file mode 100644 index 0000000000..c843e19d2a --- /dev/null +++ b/crates/ui-egui/src/documents.rs @@ -0,0 +1,357 @@ +//! Several open documents. On the desktop each has a window of its own ([`crate::windows`]); the +//! web app shows them in one window, which shows the session's active document. What the app +//! keeps about a document (AutoSave's file, comment drafts) follows the active one. In one +//! window, the window's own state about the page (drags, menus, the composition…) starts afresh +//! when the document changes ([`WordApp::sync_document`]; per-window state is listed in +//! [`crate::window_ui`]). View › Window › Switch Windows lists the open documents, File › Close +//! closes the shown one, and quitting asks about every document with unsaved changes, one after +//! another, each in its window. + +use serde_json::{Value, json}; +use wordcraft_engine::DocInfo; + +use crate::{WordApp, panes}; + +/// What the window keeps for an open document while another one is shown. +#[derive(Default)] +pub(crate) struct Parked { + /// [`WordApp`]'s `autosave_path`: the file AutoSave writes this document to. + autosave_path: Option, + /// Comment and reply text typed in this document and not yet written to it. + comment_drafts: panes::CommentDrafts, +} + +impl WordApp { + /// Follow the session's active document. When it isn't the one the window last showed, the + /// shown document's own state is put aside (or dropped, if it was closed or replaced), the + /// new one's comes back, and the window's state about the page that was shown starts afresh + /// (all of it per-window state, [`crate::window_ui::WindowUi`]): open + /// dialogs and menus, drags, the input method's composition, the status message, caches + /// keyed by revision. Its scroll position and zoom come back with it (the scroll area is per + /// document; the zoom is in the session's view). Runs after every command and every frame. + /// With native windows ([`crate::windows`]) the window that shows the document comes to the + /// front instead, or a new one opens for it. + pub(crate) fn sync_document(&mut self) { + let id = self.session.document_id(); + if self.windows.native() { + self.prune_windows(); + } + if id == self.shown_document { + return; + } + if self.windows.native() && self.follow_in_windows(id) { + return; + } + let left = std::mem::replace(&mut self.shown_document, id); + let seen = self.carry_document_state(left, id); + self.window_shows(id); + + self.canvas.show_other_document(seen); + self.controls = Default::default(); + self.dialog = None; + self.colors = None; + self.restrict = crate::dialogs_review::RestrictPane { open: self.restrict.open, ..Default::default() }; + self.status_msg = None; + self.change_picture_target = None; + self.recipient_list_pending = false; + self.dialog_pick = None; + self.focus_ribbon = false; + self.nav_search_focus = false; + self.smart_art_item = None; + self.smart_art_row_focused = false; + self.smart_art_typing = None; + self.read_aloud_at = None; + self.info_editing = None; + // Caches keyed by the revision alone: another document's revision can be the same number. + self.word_count = crate::window_ui::NOT_COUNTED; + self.find_fonts_for = None; + self.previews.document_changed(); + // The Start screen's placeholder (#1129) goes once a document is open beside it. + if let Some(placeholder) = self.no_document + && placeholder != id + && self.session.is_open(placeholder) + { + let _ = self.session.close_document(placeholder); + self.parked.remove(&placeholder); + } + } + + /// The active document is `to` and no longer `from`: `from`'s own state is put aside (or + /// dropped, if it was closed or replaced) and `to`'s comes back. Makes `to` the session's + /// active document. Returns whether `to` had state put aside. + pub(crate) fn carry_document_state(&mut self, from: u64, to: u64) -> bool { + if self.session.document_id() != to + && let Err(e) = self.session.switch_document(to) + { + log::error!("couldn't show document {to}: {e}"); + } + if from == to { + return false; + } + let leaving = Parked { autosave_path: self.autosave_path.take(), comment_drafts: std::mem::take(&mut self.comment_drafts) }; + if self.session.is_open(from) { + self.parked.insert(from, leaving); + } + let back = self.parked.remove(&to); + let seen = back.is_some(); + let back = back.unwrap_or_default(); + self.autosave_path = back.autosave_path; + self.comment_drafts = back.comment_drafts; + let session = &self.session; + self.parked.retain(|d, _| session.is_open(*d)); + seen + } + + /// Open the file at `path` for the window showing document `into` (`None`: the active + /// document's window). Every way of opening a file comes through here, so each follows the + /// same rule: an untouched blank document is replaced in its own window, else the file opens + /// in a window of its own (or its window comes forward if it is already open). A file a + /// dialog picked arrives while any window draws, so the document it was asked from is made + /// active again first. + pub fn open_file(&mut self, path: &str, into: Option) -> Result { + if let Some(id) = into + && id != self.session.document_id() + && self.session.is_open(id) + { + self.show_document(id)?; + } + self.run("file.open", json!({"path": path})) + } + + /// Show open document `id` in the window (View › Window › Switch Windows, quitting). + pub(crate) fn show_document(&mut self, id: u64) -> Result<(), String> { + self.session.activate(id).map_err(|e| e.to_string())?; + self.sync_document(); + Ok(()) + } + + /// The open documents with work that would be lost on quitting: the shown one first, then + /// the others in the order they were opened. Unsaved changes, a save the browser hasn't + /// confirmed, or comment text not yet written to the document. The Start screen's + /// placeholder is never one, though other windows' documents are. + fn unsaved_documents(&self) -> Vec { + let active = self.session.document_id(); + let mut out = Vec::new(); + if self.has_document() && (self.session.dirty || self.save_unconfirmed() || panes::has_comment_drafts(self)) { + out.push(active); + } + for d in self.session.documents() { + let unconfirmed = || self.saves_in_flight.iter().any(|s| s.marked_saved && s.document == d.id); + let drafts = || self.parked.get(&d.id).is_some_and(|p| !p.comment_drafts.is_empty()); + if d.id != active && Some(d.id) != self.no_document && (d.dirty || unconfirmed() || drafts()) { + out.push(d.id); + } + } + out + } + + /// Whether an open document has work that quitting would lose. + pub(crate) fn has_unsaved_documents(&self) -> bool { + !self.unsaved_documents().is_empty() + } + + /// Quitting: show the next document with unsaved work and ask Save / Don't Save / Cancel + /// about it (its answer goes on with [`WordApp::close_document`]'s `quit`). With none left, + /// the app quits. + pub(crate) fn ask_before_quit(&mut self) -> Value { + let Some(&next) = self.unsaved_documents().first() else { + self.quit_requested = true; + return json!({"quit": true}); + }; + if next != self.session.document_id() + && let Err(e) = self.show_document(next) + { + log::error!("quitting: couldn't show document {next}: {e}"); + return json!({"quit": false}); + } + self.ui.backstage = false; + self.run("file.close", json!({"quit": true})).unwrap_or_else(|e| json!({"quit": false, "error": e})) + } +} + +/// The name an open document goes by in the window: the engine's title, with an untitled +/// document's `Document2` in the interface language. +pub fn display_title(d: &DocInfo) -> String { + if d.path.is_none() { + return untitled_title(&d.title).unwrap_or_else(|| d.title.clone()); + } + d.title.clone() +} + +/// `Document2` (the engine's English name for an untitled document) in the interface language, +/// or None for any other name. The catalogs translate `Document1`; the number follows it. +pub(crate) fn untitled_title(english: &str) -> Option { + let n = english.strip_prefix("Document")?; + if n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let one = tl!("Document1"); + Some(match one.strip_suffix('1') { + Some(stem) => format!("{stem}{n}"), + None if n == "1" => one.to_string(), + None => format!("{one} ({n})"), + }) +} + +/// View › Window › Switch Windows: the open windows (in one window, the open documents) in the +/// order they were opened, this one marked, unsaved ones with the title bar's dot. Choosing one +/// brings that window to the front (in one window, shows the document). +pub(crate) fn switch_windows_menu(ui: &mut egui::Ui, app: &mut WordApp) { + let native = app.windows.native(); + let entries: Vec<(u64, String, bool)> = if native { + app.switch_windows_entries() + } else { + app.session.documents().into_iter().map(|d| (d.id, format!("{}{}", display_title(&d), if d.dirty { " •" } else { "" }), d.active)).collect() + }; + for (key, label, current) in entries { + if ui.add(egui::Button::selectable(current, label).min_size(egui::vec2(220.0, 0.0))).clicked() { + if native { + app.show_window(key); + } else { + let _ = app.run("view.switchWindows", json!({"id": key})); + } + ui.close(); + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use wordcraft_doc::StoryRef; + use wordcraft_engine::Session; + + use crate::{Services, WordApp}; + + fn app() -> WordApp { + WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()) + } + + fn harness(a: WordApp) -> egui_kittest::Harness<'static, WordApp> { + egui_kittest::Harness::builder().with_size(egui::vec2(2560.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ) + } + + fn text(a: &WordApp) -> String { + a.session.doc.plain_text(StoryRef::Body).trim().to_string() + } + + fn prompt(a: &WordApp) -> Option<&'static str> { + a.dialog.as_ref().map(|d| d.name()) + } + + /// Mod+N with text typed opens a second document beside the first; Switch Windows lists both + /// and brings the first back with its text. + #[test] + fn new_keeps_the_typed_document_and_switch_windows_brings_it_back() { + use egui_kittest::kittest::Queryable; + let mut a = app(); + a.run("text.insert", json!({"text": "First draft"})).unwrap(); + let first = a.session.document_id(); + let mut h = harness(a); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::N); + h.run_steps(2); + assert_ne!(h.state().session.document_id(), first, "Mod+N shows a new document"); + assert_eq!(text(h.state()), ""); + assert!(h.state().sent_title.starts_with("Document2"), "{}", h.state().sent_title); + + h.state_mut().run("ui.tab", json!({"tab": "View"})).unwrap(); + h.run_steps(2); + h.get_by_label("Switch Windows").click(); + h.run_steps(2); + use egui::accesskit::Role; + assert!(h.query_by_role_and_label(Role::Button, "Document1 •").is_some(), "the first document is listed, unsaved"); + assert!(h.query_by_role_and_label(Role::Button, "Document2").is_some(), "the new one is listed"); + h.get_by_role_and_label(Role::Button, "Document1 •").click(); + h.run_steps(2); + let a = h.state(); + assert_eq!(a.session.document_id(), first); + assert_eq!(text(a), "First draft"); + assert_eq!(a.session.documents().len(), 2); + assert!(a.sent_title.starts_with("Document1"), "{}", a.sent_title); + } + + /// Close with two documents open asks about the shown one's changes; after Don't Save the + /// other one shows. + #[test] + fn close_asks_then_shows_the_other_document() { + let mut a = app(); + a.run("text.insert", json!({"text": "Kept"})).unwrap(); + let kept = a.session.document_id(); + a.run("file.new", json!({})).unwrap(); + assert!(prompt(&a).is_none(), "New asks nothing: nothing is replaced"); + a.run("text.insert", json!({"text": "Dropped"})).unwrap(); + a.run("file.close", json!({})).unwrap(); + assert_eq!(prompt(&a), Some("saveChanges")); + a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); + assert_eq!(a.session.document_id(), kept); + assert_eq!(text(&a), "Kept"); + assert_eq!(a.session.documents().len(), 1); + assert!(a.has_document() && !a.quit_requested); + } + + /// Closing the window with a document of unsaved changes behind the shown one asks about it + /// (showing it); Cancel keeps the app open, and Don't Save quits. + #[test] + fn quitting_asks_about_a_document_that_is_not_shown() { + let mut a = app(); + a.run("text.insert", json!({"text": "Behind"})).unwrap(); + let behind = a.session.document_id(); + a.run("file.new", json!({})).unwrap(); + assert!(!a.close_requested()); + assert_eq!(a.session.document_id(), behind, "the unsaved document is shown"); + assert_eq!(prompt(&a), Some("saveChanges")); + a.run("ui.saveChanges", json!({"answer": "cancel"})).unwrap(); + assert!(!a.quit_requested); + assert_eq!(a.session.documents().len(), 2, "nothing closed"); + + assert!(!a.close_requested(), "asked again"); + a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); + assert!(a.quit_requested); + assert!(a.close_requested()); + } + + /// In one window (the web app), a request an agent's command makes for a document that isn't + /// shown never acts on the shown one: Print doesn't open Backstage over it. + #[test] + fn requests_for_a_document_not_shown_leave_the_window_alone() { + let mut a = app(); + a.windows.single(); + let first = a.session.document_id(); + a.run("file.new", json!({})).unwrap(); + a.session.with_document(first, |s| s.run("file.print", &json!({}))).unwrap().unwrap(); + for req in std::mem::take(&mut a.session.ui_requests) { + assert_eq!(req["document"], first); + a.handle_request(&req); + } + assert!(!a.ui.backstage, "Print for the other document left the window alone"); + } + + #[test] + fn untitled_names_follow_the_interface_language() { + assert_eq!(super::untitled_title("Document3").as_deref(), Some("Document3")); + assert_eq!(super::untitled_title("Documents"), None); + assert_eq!(super::untitled_title("Report"), None); + } + + /// From the Start screen, Blank document is `Document1` and New after it `Document2`: the + /// Start screen's stand-in document doesn't use up a name. + #[test] + fn start_screen_blank_document_is_document1() { + let mut a = app(); + a.ui.start_screen = true; + assert!(a.start_screen()); + a.run("file.new", json!({})).unwrap(); + let titles = |a: &WordApp| a.session.documents().into_iter().map(|d| d.title).collect::>(); + assert_eq!(titles(&a), ["Document1"]); + a.run("file.new", json!({})).unwrap(); + assert_eq!(titles(&a), ["Document1", "Document2"]); + } +} diff --git a/crates/ui-egui/src/file_browser.rs b/crates/ui-egui/src/file_browser.rs index 4f274e8e7f..463467bff7 100644 --- a/crates/ui-egui/src/file_browser.rs +++ b/crates/ui-egui/src/file_browser.rs @@ -287,7 +287,7 @@ impl WordApp { FileDialogRequest::Save { name: self.default_save_name(), dir: None }, AfterPick::SaveAs { after: crate::file_dialogs::AfterSave::Nothing, document: self.session.document_id() }, ), - _ => (FileDialogRequest::Open { purpose: "document".into() }, AfterPick::OpenDocument), + _ => (FileDialogRequest::Open { purpose: "document".into() }, AfterPick::OpenDocument { document: self.session.document_id() }), }; self.show_file_browser(req, after, false); } @@ -535,7 +535,7 @@ mod tests { std::fs::create_dir(dir.join("drafts")).unwrap(); std::fs::write(dir.join("drafts").join("old.docx"), b"x").unwrap(); let req = FileDialogRequest::Save { name: "Document1.docx".into(), dir: None }; - let mut b = FileBrowser::new(req, AfterPick::OpenDocument, dir.clone(), false); + let mut b = FileBrowser::new(req, AfterPick::OpenDocument { document: 0 }, dir.clone(), false); b.name = "drafts".into(); assert_eq!(b.accept(), Step::Stay); assert_eq!(b.dir, dir.join("drafts")); @@ -583,7 +583,8 @@ mod tests { fn an_empty_folder_says_there_are_no_items() { let dir = scratch("empty_state"); let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); - let mut b = FileBrowser::new(FileDialogRequest::Open { purpose: "document".into() }, AfterPick::OpenDocument, dir.clone(), false); + let mut b = + FileBrowser::new(FileDialogRequest::Open { purpose: "document".into() }, AfterPick::OpenDocument { document: 0 }, dir.clone(), false); b.navigate(dir.clone()); assert!(matches!(&b.listing, Ok(l) if l.entries.is_empty()), "the folder starts empty"); a.file_browser.open = Some(Box::new(b)); diff --git a/crates/ui-egui/src/file_dialogs.rs b/crates/ui-egui/src/file_dialogs.rs index f40ebf1867..7f375e8c1f 100644 --- a/crates/ui-egui/src/file_dialogs.rs +++ b/crates/ui-egui/src/file_dialogs.rs @@ -56,8 +56,8 @@ pub fn open_filters(purpose: &str) -> &'static [(&'static str, &'static [&'stati /// What to do with the path picked in a dialog. pub(crate) enum AfterPick { - /// Open the picked document. - OpenDocument, + /// Open the picked document from the window showing `document` ([`WordApp::open_file`]). + OpenDocument { document: u64 }, /// Insert the picked picture, or replace the pending Change Picture target. Picture, /// Save the document there, then carry on. @@ -242,9 +242,9 @@ impl WordApp { /// Do what the dialog was for with the picked path (`None`: the user cancelled). pub(crate) fn finish_pick(&mut self, after: AfterPick, picked: Option) -> Result { match after { - AfterPick::OpenDocument => { + AfterPick::OpenDocument { document } => { let Some(path) = picked else { return Ok(json!({"cancelled": true})) }; - let r = self.run("file.open", json!({"path": path})); + let r = self.open_file(&path, Some(document)); self.ui.backstage = false; r } @@ -315,8 +315,12 @@ impl WordApp { r } AfterPick::SaveAs { after, document } => { + // The document it was for may wait behind another one: it is shown and saved. if document != self.session.document_id() { - return Err("the document for this save has been replaced".into()); + if !self.session.is_open(document) { + return Err("the document for this save has been closed".into()); + } + self.show_document(document)?; } // The save's error, kept for the Save Changes prompt to explain (#1445). A plain // Save As (Backstage's Browse…, AutoSave) has no prompt to carry it to, so it was @@ -446,7 +450,8 @@ impl WordApp { f("document"); return; } - let _ = self.ask_file(FileDialogRequest::Open { purpose: "document".into() }, AfterPick::OpenDocument); + let _ = + self.ask_file(FileDialogRequest::Open { purpose: "document".into() }, AfterPick::OpenDocument { document: self.session.document_id() }); } pub(crate) fn pick_picture(&mut self) { @@ -601,7 +606,8 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } - /// A late Save As answer belongs to the imported document that opened the picker. + /// A late Save As answer belongs to the imported document that opened the picker: once that + /// document is closed, it writes nothing. #[test] fn imported_word_stale_save_as_never_writes_the_replacement_document() { for close in [false, true] { @@ -623,8 +629,8 @@ mod tests { } assert!(a.file_dialog_open()); let previous = a.session.document_id(); - // Automation bypasses the user prompt and can replace a document during a picker. - a.execute("file.new", json!({})).unwrap(); + // Automation bypasses the user prompt and can close a document during a picker. + a.execute("file.closeDocument", json!({"force": true})).unwrap(); typed(&mut a); assert_ne!(previous, a.session.document_id()); let draft = a.session.doc.clone(); @@ -960,14 +966,14 @@ mod tests { } /// "Save changes?" → Save on a new document: the held-back Close runs only once the later - /// Save As has actually saved; a cancelled Save As cancels the held-back New. + /// Save As has actually saved; a cancelled Save As cancels it. #[test] fn save_changes_waits_for_the_save_as_answer() { let dir = scratch("prompt"); let path = dir.join("novel.docx"); let (mut a, shown) = async_app(); typed(&mut a); - a.run("file.new", json!({})).unwrap(); + a.run("file.close", json!({})).unwrap(); a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); answer(&shown, None); a.poll_file_dialog(); diff --git a/crates/ui-egui/src/format_shape.rs b/crates/ui-egui/src/format_shape.rs index 9fa67a1256..699e2ff0a7 100644 --- a/crates/ui-egui/src/format_shape.rs +++ b/crates/ui-egui/src/format_shape.rs @@ -80,10 +80,6 @@ fn apply(app: &mut WordApp, ui: &Ui, fill: Option<&Fill>, resp: Option<&Response /// The pane, while open. pub fn pane(app: &mut WordApp, ui: &mut Ui) { - if crate::panes::header(ui, "Format Shape") { - let _ = app.run("view.formatShapePane", serde_json::json!({"value": false})); - return; - } // A drag that applied as it went ends as one undo step. if !ui.input(|i| i.pointer.any_down()) && let Some(mark) = ui.data_mut(|d| { diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index a3bfd583b7..b9b1f870bd 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -259,10 +259,14 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { return; } let events = ctx.input(|i| i.events.clone()); + let window = app.windows.shown(); if app.session.view.read_mode { // Read Mode is for navigation and selection, not typing into the document. The ribbon's // Edit Document button or Escape returns to Print Layout before editing. for e in &events { + if app.windows.shown() != window { + break; + } match e { // Not while a dialog or menu that takes Escape is open. egui::Event::Key { key: Key::Escape, pressed: true, .. } if app.escape_free => { @@ -316,6 +320,11 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { app.canvas.paste_mods = Some(held); } for e in events { + // A key closed the window (Mod+W) or brought another forward: the frame's other keys + // were typed in this one and are dropped. + if app.windows.shown() != window { + break; + } interrupted_preedit |= std::mem::take(&mut app.canvas.ime_interrupted_preedit); let stale_composition_event = if interrupted_preedit { match &e { @@ -674,7 +683,7 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { } continue; } - if leave_full_screen(app, key, modifiers, true) { + if leave_full_screen(app, ctx, key, modifiers, true) { continue; } app.canvas.swallow_text = dispatch(app, key, modifiers) && modifiers.alt; @@ -688,12 +697,17 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { /// took it: a pen, a text box, a header, a dialog, a menu or a popup, a drag on the page, and in /// the document (`canvas`) a selection, which Escape collapses first (`select.collapse`), as in a /// normal window. -fn leave_full_screen(app: &mut WordApp, key: Key, m: Modifiers, canvas: bool) -> bool { +fn leave_full_screen(app: &mut WordApp, ctx: &egui::Context, key: Key, m: Modifiers, canvas: bool) -> bool { let screen = crate::chrome::Screen::of(&app.session.view).exit_command(); let Some(id) = screen.or(app.session.view.full_screen.then_some("view.fullScreen")) else { return false }; if key != Key::Escape || !m.is_none() || !app.escape_free { return false; } + // Escape with a button held (dragging a task pane's title bar, say) cancels the drag only. + // egui has already dropped the drag itself, so the held button is what is left of it. + if ctx.input(|i| i.pointer.any_down()) { + return false; + } let c = &app.canvas; if c.obj_drag.is_some() || c.table_resize.is_some() || c.table_drag.is_some() || c.link_from.is_some() { return false; @@ -1469,16 +1483,27 @@ fn is_global_shortcut(key: Key, m: Modifiers) -> bool { m.command || m.alt || is_function_key(key) } -/// Command shortcuts when the canvas isn't focused but no text field is either. +/// Command shortcuts when the canvas isn't focused and no text field is: nothing has the +/// keyboard, or a button or a pane's title bar does. A focused widget in an open dialog or popup +/// keeps the keys, so Ctrl+Z on a dialog button can't undo the document behind it. pub fn global_shortcuts(app: &mut WordApp, ctx: &egui::Context) { // Customize Keyboard records the keys pressed; they never run commands meanwhile. - if app.canvas.focused || ctx.egui_wants_keyboard_input() || matches!(app.dialog, Some(crate::dialogs::Dialog::CustomizeKeyboard { .. })) { + if app.canvas.focused + || ctx.text_edit_focused() + || (ctx.egui_wants_keyboard_input() && crate::panes::overlay_open(app, ctx)) + || matches!(app.dialog, Some(crate::dialogs::Dialog::CustomizeKeyboard { .. })) + { return; } let events = ctx.input(|i| i.events.clone()); + let window = app.windows.shown(); for e in events { + // The rest of the keys were pressed in a window that closed or went to the back. + if app.windows.shown() != window { + break; + } if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { - if leave_full_screen(app, key, modifiers, false) { + if leave_full_screen(app, ctx, key, modifiers, false) { continue; } if is_global_shortcut(key, modifiers) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 2d99dbe5f5..adace28030 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -49,6 +49,7 @@ pub mod dialogs_ribbon; pub mod dialogs_smart_art; pub mod dialogs_table_style; pub mod dialogs_templates; +pub mod documents; pub mod equation_tab; /// The built-in file browser, for desktops without a system file dialog (#576). #[cfg(not(target_arch = "wasm32"))] @@ -87,7 +88,8 @@ pub mod updates_ui; pub mod widgets; pub mod window_close; pub mod window_geometry; -pub mod windows; +mod window_ui; +mod windows; pub mod wordart_menus; pub mod zotero; @@ -185,8 +187,6 @@ pub struct Services { /// `xdg-mime`. `None` (web, macOS) hides the button; macOS shows how to do it in Finder /// instead. An error means nothing changed. pub make_default_app: Option Result>>, - /// Desktop: the other WordCraft windows and a channel to them, for View › Window (#322). - pub windows: Option>, /// Desktop: the app's own user templates folder (`Templates` in the settings folder, #2852), /// used while File › Options names none. File › New lists personal templates from it and Save /// as Word Template starts in it. `None` (web) hides personal templates. @@ -295,6 +295,22 @@ pub struct UiState { /// View › Navigation Pane: open or closed as the person left it, kept between runs (#2526). /// The session owns it while running (see [`WordApp::prefs`]). pub nav_pane: bool, + /// Where the Styles pane sits (#1756). The session owns it while running (see [`WordApp::prefs`]). + #[serde(deserialize_with = "lenient_styles_pane_dock")] + pub styles_pane_dock: wordcraft_engine::StylesPaneDock, + /// The floating Styles pane's `[x, y, width, height]`, also kept while it is docked. + #[serde(deserialize_with = "lenient_float_rect")] + pub styles_pane_float: Option<[f32; 4]>, + /// The docked Styles pane's width this run; like the other panes' widths, not kept between runs. + #[serde(skip)] + pub styles_pane_width: Option, + /// Where each of the other task panes sits, by [`panes::Pane::key`] (#1756). + #[serde(deserialize_with = "panes::lenient_places")] + pub pane_places: std::collections::BTreeMap, + /// The task panes in the order the user put them, by [`panes::Pane::key`]: each edge shows its + /// panes in this order, the first outermost. Empty: the default order. + #[serde(deserialize_with = "panes::lenient_order", skip_serializing_if = "Vec::is_empty")] + pub pane_order: Vec, /// Headings whose sub-headings the Navigation pane hides (#1997); per session, not saved. #[serde(skip)] pub nav_collapsed: std::collections::HashSet, @@ -383,6 +399,9 @@ pub struct UiState { /// no longer applies (a contextual tab after the selection moved away, a tab hidden in Customize /// Ribbon). Word opens on Home every launch; see [`UiState::tab`]. pub const DEFAULT_TAB: &str = "Home"; + +/// ⌘Q, which only the Mac sends: quit. +const QUIT: egui::KeyboardShortcut = egui::KeyboardShortcut::new(egui::Modifiers::MAC_CMD, egui::Key::Q); /// Most recent documents kept (File › Options › Advanced offers 0–50, as Word does). pub const MAX_RECENT: usize = 50; /// Most custom dictionary words read back from `ui.json`. @@ -412,6 +431,11 @@ impl Default for UiState { legacy_dark: None, nav_tab: "headings".into(), nav_pane: false, + styles_pane_dock: wordcraft_engine::StylesPaneDock::Right, + styles_pane_float: None, + styles_pane_width: None, + pane_places: Default::default(), + pane_order: Vec::new(), nav_collapsed: Default::default(), show_discord: true, author: String::new(), @@ -503,6 +527,18 @@ pub fn recent_fonts_from<'a>(names: impl IntoIterator) -> Vec>(d: D) -> Result { + let v = Value::deserialize(d)?; + Ok(v.as_str().and_then(wordcraft_engine::StylesPaneDock::parse).unwrap_or_default()) +} + +/// Anything but four finite numbers reads as no saved rect; `craft_ui::panes` brings a saved one +/// back inside the window. +fn lenient_float_rect<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + Ok(panes::float_rect(&Value::deserialize(d)?)) +} + /// `recentFonts` as saved, through [`recent_fonts_from`]: an entry that isn't a name is dropped, /// not the whole list. fn lenient_recent_fonts<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { @@ -694,6 +730,16 @@ pub struct WordApp { no_document: Option, /// Comment and reply text typed but not yet written to the document (#1348). pub(crate) comment_drafts: panes::CommentDrafts, + /// The document the window's per-document state belongs to; when the session's active + /// document is another, [`WordApp::sync_document`] puts that state aside and brings the + /// other's. + shown_document: u64, + /// The per-document state of the open documents not shown. + parked: std::collections::HashMap, + /// The open windows ([`windows`]). + pub(crate) windows: windows::Windows, + /// The document the last print job was for: its window reports how the job went. + print_document: Option, } /// The answer to "Do you want to save changes?" (`ui.saveChanges`). @@ -724,22 +770,75 @@ impl WordApp { if let Some(tag) = sys_locale::get_locale() { wordcraft_engine::set_system_locale(&tag); } + // What the window shows starts as a new window's. + let window_ui::WindowUi { + canvas, + controls, + dialog, + colors, + restrict, + status_msg, + previews, + change_picture_target, + recipient_list_pending, + dialog_pick, + focus_ribbon, + control_click, + keytip_rects, + nav_search_focus, + comment_fonts_for, + find_fonts_for, + sent_title, + sent_full_screen, + seen_full_screen, + escape_free, + word_count, + last_autosave, + equation_prev_tab, + smart_art_item, + smart_art_row_focused, + smart_art_typing, + outline_prev_tab, + read_aloud_at, + awaiting_save, + file_dialog, + info_editing, + shown_document, + #[cfg(not(target_arch = "wasm32"))] + file_browser, + tab, + backstage, + backstage_page, + print, + nav_collapsed, + keytip_prefix, + keytips, + alt_pressed, + alt_chord_used, + alt_key_down, + alt_modifier_seen, + } = window_ui::WindowUi::new(session.document_id()); WordApp { session, - ui: UiState::default(), + ui: UiState { + tab, + backstage, + backstage_page, + print, + nav_collapsed, + keytip_prefix, + keytips, + alt_pressed, + alt_chord_used, + alt_key_down, + alt_modifier_seen, + ..UiState::default() + }, services, printing: Default::default(), print_feedback_seen: None, - canvas: canvas::CanvasState::default(), - controls: Default::default(), - dialog: None, - colors: None, - restrict: Default::default(), - status_msg: None, - previews: previews::Previews::default(), integrated_titlebar: false, mac_titlebar: chrome::MacTitleBar::default(), - focus_ribbon: false, updates_rt: updates_ui::Runtime::default(), control_rx: None, shots: control::Shots::default(), @@ -749,18 +848,13 @@ impl WordApp { synthetic: Vec::new(), synthetic_pending: false, synthetic_injected: 0, - control_click: false, - keytip_rects: Vec::new(), - nav_search_focus: false, styled: false, fonts_hans: false, fonts_korean: false, fonts_system_cjk: false, want_system_cjk: false, - comment_fonts_for: None, recover_fonts_for: None, recent_fonts_for: Vec::new(), - find_fonts_for: None, fonts_frames: 0, applied_theme: None, native_scale: 1.0, @@ -772,38 +866,55 @@ impl WordApp { system_animations: None, applied_animations: None, frame_ms: 0.0, - sent_title: String::new(), - sent_full_screen: false, - seen_full_screen: None, - escape_free: false, quit_requested: false, autosave: true, - word_count: (0, 0), - last_autosave: 0.0, - equation_prev_tab: None, - smart_art_item: None, - smart_art_typing: None, - smart_art_row_focused: false, - outline_prev_tab: None, zotero: zotero::ZoteroLink::default(), ctx: None, ime_origin: false, - read_aloud_at: None, read_aloud_error: None, autosave_path: None, saves_in_flight: Vec::new(), - awaiting_save: None, - change_picture_target: None, - recipient_list_pending: false, font_generation: wordcraft_fonts::FontDb::global().generation(), - dialog_pick: None, - file_dialog: None, - #[cfg(not(target_arch = "wasm32"))] - file_browser: file_browser::State::default(), - info_editing: None, recovery_seen: false, no_document: None, comment_drafts: Default::default(), + parked: Default::default(), + windows: windows::Windows::new(shown_document), + print_document: None, + canvas, + controls, + dialog, + colors, + restrict, + status_msg, + previews, + change_picture_target, + recipient_list_pending, + dialog_pick, + focus_ribbon, + control_click, + keytip_rects, + nav_search_focus, + comment_fonts_for, + find_fonts_for, + sent_title, + sent_full_screen, + seen_full_screen, + escape_free, + word_count, + last_autosave, + equation_prev_tab, + smart_art_item, + smart_art_row_focused, + smart_art_typing, + outline_prev_tab, + read_aloud_at, + awaiting_save, + file_dialog, + info_editing, + shown_document, + #[cfg(not(target_arch = "wasm32"))] + file_browser, } } @@ -824,6 +935,7 @@ impl WordApp { self.ui.backstage = true; self.ui.backstage_page = "home".into(); self.no_document = Some(self.session.document_id()); + self.session.release_untitled_name(); } self.ui.start_screen } @@ -846,6 +958,7 @@ impl WordApp { ui.read_aloud_skip_citations = self.session.read_aloud.skip_citations; ui.dark_page = self.session.view.dark_mode; ui.nav_pane = self.session.view.nav_pane; + ui.styles_pane_dock = self.session.view.styles_pane_dock; ui.check_spelling = self.session.view.proofing; ui.keyboard = self.session.keymap.clone(); ui.ribbon = self.session.ribbon.clone(); @@ -894,6 +1007,7 @@ impl WordApp { self.session.read_aloud.skip_citations = self.ui.read_aloud_skip_citations; self.session.view.dark_mode = self.ui.dark_page; self.session.view.nav_pane = self.ui.nav_pane; + self.session.view.styles_pane_dock = self.ui.styles_pane_dock; self.session.view.proofing = self.ui.check_spelling; // Keys for commands this version doesn't have are dropped. self.session.keymap = std::mem::take(&mut self.ui.keyboard); @@ -959,7 +1073,10 @@ impl WordApp { self.dialog = dialogs::Dialog::open(name, self); return Ok(json!({"pending": name})); } - if (self.session.dirty || self.save_unconfirmed() || refused.is_some()) && discards_document(id, ¶ms) { + if (self.session.dirty || self.save_unconfirmed() || refused.is_some()) + && discards_document(id, ¶ms) + && !self.closes_only_its_window(id, ¶ms) + { let name = self.prompt_name(); self.dialog = Some(dialogs::Dialog::SaveChanges { name, @@ -1159,17 +1276,13 @@ impl WordApp { // Its HTML too when it has some (an equation as MathML, #816). crate::system_clipboard::put_result(self, ctx, v); } - // When the document has been replaced (not when Open only showed its picker), a pending - // "Save changes?" and AutoSave's go-ahead were both about the old one. + // Another document is shown (not when Open only showed its picker): the window's state + // for the one it showed is put aside. if self.session.document_id() != document { // A requested New/Open wins over the asynchronous startup recovery scan. // Older copies remain available through File > Recover. self.recovery_seen = true; - self.autosave_path = None; - if matches!(self.dialog, Some(dialogs::Dialog::SaveChanges { .. })) { - self.dialog = None; - } - crate::table_pen::stop(self); + self.sync_document(); } // An explicit save to the document's own file lets AutoSave keep writing there. if let Ok(v) = &r @@ -1388,6 +1501,9 @@ impl WordApp { async_saves::SaveOutcome::Cancelled | async_saves::SaveOutcome::Failed(_) => { if save.marked_saved && save.document == current { self.session.dirty = true; + } else if save.marked_saved { + // Another document is shown: the one saved is unsaved again where it waits. + let _ = self.session.with_document(save.document, |s| s.dirty = true); } } } @@ -1451,39 +1567,67 @@ impl WordApp { if self.quit_requested { return true; } - // No document open (#1129): nothing to save. - if !self.has_document() { + // Nothing to save, also with no document open in this window (#1129) and none elsewhere. + if !self.has_unsaved_documents() { return true; } // A comment draft the document refuses is an unsaved change: the prompt can drop it. - let refused = panes::flush_comment_drafts(self).is_err(); - if !self.session.dirty && !self.save_unconfirmed() && !refused { + if self.has_document() { + let _ = panes::flush_comment_drafts(self); + } + if !self.has_unsaved_documents() { return true; } - // `quit`: once the prompt is answered the window closes too, not just the document. - let _ = self.run("file.close", json!({"quit": true})); - false + // Each document with unsaved work is shown and asked about in turn; Cancel stops. + self.ask_before_quit(); + self.quit_requested } - /// File › Close (Mod+W, Mod+F4) closes the document and keeps the window, as Word does - /// (#1712): it shows no page, as after Esc on the Start screen (#1129), and File › New or Open - /// goes on from there. `{"quit": true}`, from the window's close button, quits instead. With no - /// document open there is nothing to close. Unsaved changes are never discarded unasked: a - /// dirty document closes only with `{"force": true}` (Don't Save in the prompt passes it). + /// File › Close (Mod+W, Mod+F4) closes the shown document, or only its window while other + /// windows show the document (View › New Window), asking nothing then. Its window closes too while other + /// windows are open, and the one focused before it comes forward (in the web app's one window, + /// the next document shows). The last window stays, as Word's does (#1712), with no page, as + /// after Esc on the Start screen (#1129), and File › New or Open goes on from there. `{"quit": true}`, + /// from the window's close button, goes on quitting: the shown document's prompt has been + /// answered, so it closes and the next document with unsaved changes is asked about + /// ([`WordApp::ask_before_quit`]). With no document open there is nothing to close. Unsaved + /// changes are never discarded unasked: a dirty document closes only with `{"force": true}` + /// (Don't Save in the prompt passes it). fn close_document(&mut self, params: &Value) -> Result { + if self.closes_only_its_window("file.close", params) && self.close_window_only() { + return Ok(json!({"closed": true, "window": true})); + } if let Some(e) = control::close_refused(self, "file.close", params) { return Err(e.into()); } + let last = self.session.documents().len() <= 1; if params.get("quit").and_then(Value::as_bool) == Some(true) { - self.quit_requested = true; - return Ok(json!({"quit": true})); + if last { + // Don't Save: the document stays open until the app exits, but its changes, + // and so its recovery copy, are discarded. + if params.get("force").and_then(Value::as_bool) == Some(true) { + self.session.discard_recovery_copy(); + } + self.quit_requested = true; + return Ok(json!({"quit": true})); + } + self.close_shown_document()?; + return Ok(self.ask_before_quit()); } if !self.has_document() { return Ok(json!({"closed": false})); } - self.execute("file.new", json!({}))?; - self.no_document = Some(self.session.document_id()); - self.ui.backstage = false; + self.close_shown_document()?; + self.recovery_seen = true; + if last { + self.no_document = Some(self.session.document_id()); + self.session.release_untitled_name(); + } + // The window that stays leaves the File page; another window that came forward keeps + // its own. + if last || !self.windows.native() { + self.ui.backstage = false; + } Ok(json!({"closed": true})) } @@ -1555,7 +1699,18 @@ impl WordApp { } /// Requests commands make of the UI (open a dialog…). - fn handle_request(&mut self, req: &Value) { + pub(crate) fn handle_request(&mut self, req: &Value) { + // Made for a document that isn't shown (an agent's command run there): its window handles + // it when it draws. + if let Some(document) = req.get("document").and_then(Value::as_u64) + && document != self.session.document_id() + { + let app_wide = req.get("interfaceTheme").is_some() || req.get("toggle").and_then(Value::as_str) == Some("interfaceTheme"); + if app_wide || req.get("close").is_some() || !self.queue_for_window(document, req) { + self.handle_request_for(document, req); + } + return; + } // Ctrl+F: type straight into the Navigation pane's search box (#712). if req.get("focus").and_then(Value::as_str) == Some("navSearch") { self.nav_search_focus = true; @@ -1593,13 +1748,41 @@ impl WordApp { } } "pasteSpecial" => self.dialog = Some(dialogs::Dialog::paste_special(self, req)), + "newWindow" => self.new_window(), other => self.dialog = dialogs::Dialog::open(other, self), } } if req.get("close").is_some() { self.quit_requested = true; } - windows::request(self, req); + windows::arrange::request(self, req); + } + + /// A request made for document `document`, which isn't shown and which no other window + /// handles: the interface theme is the app's and changes; Close closes that document if it + /// has nothing unsaved; the rest (dialogs, panes, focus, scrolling) are about a window's page + /// and are dropped, as no window shows the document. + fn handle_request_for(&mut self, document: u64, req: &Value) { + let theme_only = req.get("interfaceTheme").is_some() || req.get("toggle").and_then(Value::as_str) == Some("interfaceTheme"); + if theme_only { + let mut req = req.clone(); + if let Some(o) = req.as_object_mut() { + o.remove("document"); + } + self.handle_request(&req); + return; + } + if req.get("close").is_some() { + let dirty = self.session.documents().iter().any(|d| d.id == document && d.dirty); + if dirty { + log::warn!("not closing document {document}: it has unsaved changes"); + } else if let Err(e) = self.session.close_document(document) { + log::warn!("closing document {document}: {e}"); + } + self.sync_document(); + return; + } + log::warn!("dropped a request for document {document}, which no window shows: {req}"); } /// Follow the desktop's light/dark choice while the app runs (Linux, where winit reports no @@ -1700,6 +1883,7 @@ impl WordApp { self.canvas.want_focus = true; json!({"pending": "contextMenu"}) } + "ui.paneDock" => return Some(panes::dock_command(self, p)), "ui.collapseRibbon" => { self.ui.ribbon_collapsed = !self.ui.ribbon_collapsed; json!({"collapsed": self.ui.ribbon_collapsed}) @@ -1934,6 +2118,7 @@ impl WordApp { /// Submit the selected print job; the implementation lives beside its localized controls. fn print_to_system(&mut self, p: &Value) -> Result { + self.print_document = Some(self.session.document_id()); printing::submit(self, p) } @@ -2224,7 +2409,9 @@ impl WordApp { Some(p) => p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| tl!("Document1").into()), None => match &self.session.file_name { Some(name) => name.clone(), - None if self.session.doc.core.title.is_empty() => tl!("Document1").into(), + None if self.session.doc.core.title.is_empty() => { + documents::untitled_title(&self.session.resolved_filenames().0).unwrap_or_else(|| tl!("Document1").into()) + } None => self.session.doc.core.title.clone(), }, } @@ -2233,6 +2420,11 @@ impl WordApp { /// Terminal feedback also reaches the normal window when printing starts through control. pub fn poll_printing(&mut self, ctx: &egui::Context) { self.printing.poll(ctx); + self.print_feedback(); + } + + /// Say how the print job went, once, in the window showing the printed document. + fn print_feedback(&mut self) { if self.printing.message == self.print_feedback_seen { return; } @@ -2331,32 +2523,29 @@ impl WordApp { if self.ctx.is_none() { self.ctx = Some(ctx.clone()); } - // Capture geometry/document identity is current before control admission and completion. - // The epoch is monotonic: resizing back to an earlier size is still a new epoch. - let capture_geometry = self.surface_geometry.map(|surface| surface.with_document(self.session.document_id())); - if let Some(geometry) = capture_geometry - && self.capture_geometry != Some(geometry) - { - self.capture_geometry = Some(geometry); - self.capture_epoch = self.capture_epoch.wrapping_add(1); + // Capture geometry/window/document identity is current before control admission and + // completion. + self.refresh_capture_geometry(ctx); + let native = self.windows.native(); + if native { + self.printing.poll(ctx); + } else { + self.poll_printing(ctx); } - self.poll_printing(ctx); self.drain_control(ctx); - zotero::poll(self, ctx); - read_aloud::poll(self, ctx); - windows::poll(self, ctx); - self.clear_stale_change_picture(); - let _ = self.poll_file_dialog(); + // The active window's own work; with native windows each window does it again as it + // draws, for itself. + self.window_work(ctx); self.drain_inbox(); self.drain_pasted(); self.watch_fonts(ctx); + // A script, an agent or a later result may have made another document the active one. + self.sync_document(); let document = self.session.document_id(); self.session.recovery_tick(); if self.session.document_id() != document { - self.autosave_path = None; - self.dialog = None; self.ui.backstage = false; - self.canvas.want_focus = true; + self.sync_document(); } if !self.recovery_seen && self.session.recovery_ready() { self.recovery_seen = true; @@ -2368,21 +2557,68 @@ impl WordApp { if let Some(wait) = self.session.recovery_wait() { ctx.request_repaint_after(wait); } + if !native { + self.window_input(ctx); + } + self.send_control_input(ctx); + // Screenshot deadlines, geometry and completions belong to `logic`; issuing the one + // callback command belongs to `ui` (`flush_shot_and_synthetic`). A queued/settling capture + // needs a real UI pass, and so does an injected synthetic step, even while the window is + // hidden. + control::drain_screenshots(self, ctx); + if self.shots.in_flight() || self.synthetic_pending || !self.synthetic.is_empty() { + let viewport = self.active_viewport(); + if ctx.input_for(viewport, |i| i.viewport().visible()) != Some(true) { + ctx.send_viewport_cmd_to(viewport, egui::ViewportCommand::RequestPaintWhileHidden); + } + ctx.request_repaint_of(viewport); + ctx.request_repaint(); + } + // The open above may have changed the file name the caption draws. + self.ensure_caption_fonts(); + self.ensure_comment_fonts(); + self.ensure_recover_fonts(); + self.ensure_recent_fonts(); + self.ensure_find_fonts(); + self.install_ui_fonts(ctx, lang); + self.report_dirty(); + } + + /// A window's own work, with its state in place: print feedback for its document, Zotero, + /// Read Aloud, its file dialog, AutoSave of its document. Runs in [`WordApp::logic`] for the + /// active window and, with native windows, as each window draws. + fn window_work(&mut self, ctx: &egui::Context) { + windows::arrange::poll(self, ctx); + if self.windows.native() { + let doc = self.session.document_id(); + if self.print_document.is_none_or(|d| d == doc || !self.session.is_open(d)) { + self.print_feedback(); + } + } + zotero::poll(self, ctx); + read_aloud::poll(self, ctx); + self.clear_stale_change_picture(); + let _ = self.poll_file_dialog(); self.autosave_tick(now_ms()); if self.autosaves() && self.session.dirty { ctx.request_repaint_after(std::time::Duration::from_millis(1000)); } - // Screenshot deadlines, geometry and completions belong to `logic`; issuing the one - // callback command belongs to `ui` (below). A queued/settling capture needs a real UI - // pass, and so does an injected synthetic step, even while the window is hidden. - control::drain_screenshots(self, ctx); - if self.shots.in_flight() || self.synthetic_pending || !self.synthetic.is_empty() { - if ctx.input(|i| i.viewport().visible()) != Some(true) { - ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden); - } - ctx.request_repaint(); + } + + /// A window's own input before it draws: ⌘Q. Runs in [`WordApp::logic`] with one window, + /// else as each window draws (each native window has its own input). The SmartArt Text + /// Pane's Tab keys and dropped files are read in the first UI pass + /// ([`WordApp::handle_ui_input_events`]). + fn window_input(&mut self, ctx: &egui::Context) { + if self.windows.native() && ctx.input_mut(|i| i.consume_shortcut(&QUIT)) { + self.quit(); + } + // The drawn window's caption, comments and Find results borrow faces it may not have. + if self.windows.native() { + self.ensure_caption_fonts(); + self.ensure_comment_fonts(); + self.ensure_find_fonts(); } - self.report_dirty(); } /// Files dropped on the native window. Raw input is taken only in the first UI pass: a @@ -2408,7 +2644,7 @@ impl WordApp { } else if wordcraft_fonts::is_font_file_name(&lp) { self.run("font.add", json!({"path": path})) } else { - self.run("file.open", json!({"path": path})) + self.open_file(&path, None) }; } if self.session.document_id() != before { @@ -2432,17 +2668,29 @@ impl WordApp { let system_cjk = self.fonts_system_cjk || self.want_system_cjk || lang.uses_cjk(); if !self.styled || lang.prefers_hans() != self.fonts_hans || lang.prefers_korean() != self.fonts_korean || system_cjk != self.fonts_system_cjk { - theme::install_fonts_language_extra(ctx, lang.prefers_hans(), lang.prefers_korean(), system_cjk, &self.canvas.ime_faces); + // Every window's borrowed faces: the fonts are the app's. + let mut faces = self.canvas.ime_faces.clone(); + for w in self.other_windows() { + for face in &w.canvas.ime_faces { + if !faces.iter().any(|f| f.id() == face.id()) { + faces.push(face.clone()); + } + } + } + theme::install_fonts_language_extra(ctx, lang.prefers_hans(), lang.prefers_korean(), system_cjk, &faces); self.fonts_hans = lang.prefers_hans(); self.fonts_korean = lang.prefers_korean(); self.fonts_system_cjk = system_cjk; // Mod with -, = and 0 are Word shortcuts (optional hyphen, subscript, paragraph spacing); // egui's keyboard zoom would also scale the whole window on them. Zoom is View › Zoom. // egui quits on Command-Q, which is Ctrl+Q off the Mac: Word's Reset Paragraph - // Formatting there (#1218). Keep ⌘Q, which only the Mac sends. + // Formatting there (#1218). Keep ⌘Q, which only the Mac sends. egui would close only the + // window it was pressed in, so with native windows the app quits itself + // ([`WordApp::window_logic_before`]). + let quit = (!self.windows.native()).then_some(QUIT).into_iter().collect(); ctx.options_mut(|o| { o.zoom_with_keyboard = false; - o.quit_shortcuts = vec![egui::KeyboardShortcut::new(egui::Modifiers::MAC_CMD, egui::Key::Q)]; + o.quit_shortcuts = quit; }); self.styled = true; } @@ -2517,7 +2765,9 @@ impl WordApp { // Capture the surface from the raw input itself. `InputState::viewport_rect` and the // last painted screen rect are stale in a hidden `run_logic` tick; RawInput is the host's // current answer, including minimized/occluded and a Wayland `inner_rect: None`. - if let Some(geometry) = control::SurfaceGeometry::from_raw(raw, ctx) { + if raw.viewport_id == egui::ViewportId::ROOT + && let Some(geometry) = control::SurfaceGeometry::from_raw(raw, ctx) + { // A hidden resize and change back still invalidate an older capture, even if // neither intermediate geometry reaches a drawing or logic pass. if self.surface_geometry != Some(geometry) { @@ -2528,18 +2778,11 @@ impl WordApp { if cfg!(target_os = "macos") { control_click_as_secondary(&mut raw.events, &mut self.control_click); } - if self.synthetic_pending || self.synthetic.is_empty() || self.fonts_frames < 2 { + if self.synthetic_pending || self.synthetic.is_empty() || self.fonts_frames < 2 || !self.control_input_is_roots(ctx) { return; } - let n = match self.synthetic.first() { - Some(egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. }) => 1, - _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), - }; - if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() { - raw.events.push(egui::Event::PointerMoved(*p)); - } - let n = n.min(self.synthetic.len()); - if self.synthetic.iter().take(n).any(|e| matches!(e, egui::Event::Key { .. } | egui::Event::Text(_))) { + let Some((events, n)) = self.peek_synthetic() else { return }; + if events.iter().any(|e| matches!(e, egui::Event::Key { .. } | egui::Event::Text(_))) { // Control input belongs to this window's remembered widget even while another app // is active. Give only this input frame egui focus, without requesting OS activation. raw.focused = true; @@ -2548,13 +2791,53 @@ impl WordApp { self.canvas.focused = ctx.memory(|m| m.has_focus(id)); } } - raw.events.extend(self.synthetic.iter().take(n).cloned()); + raw.events.extend(events); self.synthetic_pending = true; self.synthetic_injected = n; } - /// Lay out the window. + /// The control channel's next input frame, taken off the queue. + fn next_synthetic(&mut self) -> Option> { + let (events, n) = self.peek_synthetic()?; + self.synthetic.drain(..n); + Some(events) + } + + /// The control channel's next input frame: one pointer event (after a move to it), or keys + /// and text up to the next key release; with how many queued events it takes. + fn peek_synthetic(&self) -> Option<(Vec, usize)> { + if self.synthetic.is_empty() { + return None; + } + let n = match self.synthetic.first() { + Some(egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. }) => 1, + _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), + }; + let mut events = Vec::new(); + if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() { + events.push(egui::Event::PointerMoved(*p)); + } + let n = n.min(self.synthetic.len()); + events.extend(self.synthetic.iter().take(n).cloned()); + Some((events, n)) + } + + /// Lay out the windows: on the desktop each open document's in its own native window + /// ([`windows`]), on the web the one window. pub fn ui(&mut self, ui: &mut egui::Ui) { + if self.windows.native() { + self.windows_ui(ui); + } else { + self.window_ui(ui); + } + } + + /// Lay out the drawn window. A command that brings another window's state in (New, Open, + /// Switch Windows) ends this window's drawing for the frame: the rest would be the other + /// window's. + fn window_ui(&mut self, ui: &mut egui::Ui) { + let window = self.windows.shown(); + let moved = |app: &WordApp| app.windows.shown() != window; let t0 = now_ms(); let ctx = ui.ctx().clone(); i18n::set_current(i18n::Lang::from_pref(&self.ui.language)); @@ -2581,8 +2864,13 @@ impl WordApp { backstage::forget_save_as_error(&ctx); } self.paint_window(ui, &ctx, t); - self.show_overlays(ui, &ctx); - self.sync_title_and_frame(&ctx, t0); + if !moved(self) { + self.show_overlays(ui, &ctx); + } + if !moved(self) { + self.sync_title_and_frame(&ctx, t0); + } + // The synthetic step this frame took is done even when drawing ended early. self.flush_shot_and_synthetic(&ctx); } @@ -2616,6 +2904,8 @@ impl WordApp { /// Draw the File page, the empty-window chrome, or the normal document window. fn paint_window(&mut self, ui: &mut egui::Ui, ctx: &egui::Context, t: theme::Tokens) { + let window = self.windows.shown(); + let moved = |app: &WordApp| app.windows.shown() != window; if self.ui.backstage { backstage::show(self, ui); } else if !self.has_document() { @@ -2632,24 +2922,39 @@ impl WordApp { chrome::screen_bar(self, ui, screen); } else { chrome::title_bar(self, ui); + if moved(self) { + return; + } ribbon::show(self, ui); + if moved(self) { + return; + } chrome::status_bar(self, ui); panes::show(self, ui); } + if moved(self) { + return; + } let fill = if screen == chrome::Screen::Focus { chrome::FOCUS_CANVAS } else { t.canvas }; egui::CentralPanel::default().frame(egui::Frame::NONE.fill(fill)).show(ui, |ui| { canvas::show(self, ui); }); - if self.focus_ribbon { + if self.focus_ribbon && !moved(self) { chrome::focus_ribbon(self, ctx); } } } - /// Dialogs, panes and other overlays drawn over the window. + /// Dialogs, panes and other overlays drawn over the window. A command that brings another + /// window's state in ends the drawing: the rest would be the other window's. fn show_overlays(&mut self, ui: &mut egui::Ui, ctx: &egui::Context) { + let window = self.windows.shown(); + let moved = |app: &WordApp| app.windows.shown() != window; content_controls::popup(self, ctx); dialogs::show(self, ctx); + if moved(self) { + return; + } updates_ui::show(self, ctx); #[cfg(not(target_arch = "wasm32"))] file_browser::show(self, ctx); @@ -2657,7 +2962,13 @@ impl WordApp { zotero::show_alert(self, ctx); read_aloud::show(self, ctx); crate::keytips::show(self, ctx, ui); + if moved(self) { + return; + } keys::global_shortcuts(self, ctx); + if moved(self) { + return; + } self.refocus_after_chrome_click(ctx); if let Some(url) = self.canvas.open_url.take() { ctx.open_url(egui::OpenUrl::new_tab(url)); @@ -2666,13 +2977,12 @@ impl WordApp { /// Keep the window title and full-screen state in sync, then record this frame's time. fn sync_title_and_frame(&mut self, ctx: &egui::Context, t0: f64) { - let title = if self.has_document() { - format!("{}{} - WordCraft", self.title_stem(), if self.session.dirty { " •" } else { "" }) - } else { - "WordCraft".to_string() - }; + // With native windows the viewport driver gives the window its title. + let title = self.window_title(); if title != self.sent_title { - ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); + if !self.windows.driven() { + ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); + } self.sent_title = title; } self.sync_full_screen(ctx); @@ -2825,6 +3135,17 @@ impl WordApp { } } + /// Make the capture geometry the active window's surface, viewport and document. The epoch is + /// monotonic: resizing back to an earlier size is still a new epoch. + pub(crate) fn refresh_capture_geometry(&mut self, ctx: &egui::Context) { + let (viewport, surface) = self.active_surface_geometry(ctx); + let geometry = surface.map(|surface| surface.with_target(viewport, self.session.document_id())); + if self.capture_geometry != geometry { + self.capture_geometry = geometry; + self.capture_epoch = self.capture_epoch.wrapping_add(1); + } + } + /// Drains queued control requests, dispatches each through `control::handle`, sends its reply or /// admits a screenshot, then puts the receiver back. fn drain_control(&mut self, ctx: &egui::Context) { @@ -3055,20 +3376,11 @@ fn flushes_comment_drafts(id: &str, params: &Value) -> bool { ) || discards_document(id, params) } -/// User commands that replace or close the document (New, also from a template; `file.open` without a path only shows the -/// file picker; the open that follows is checked). Envelopes, Labels and Finish & Merge make a -/// new document in its place; a merge written to a file (`path`) and an envelope added to this -/// document (`add`) leave it alone. -pub(crate) fn discards_document(id: &str, params: &Value) -> bool { - match id { - "file.new" | "file.newFromTemplate" | "file.close" | "mailings.labels" => true, - "mailings.envelopes" => params.get("add").and_then(Value::as_bool) != Some(true), - "file.open" => params.get("path").is_some(), - "file.recover" => params.get("restore").is_some(), - "mailings.finish" => params.get("path").and_then(Value::as_str).is_none(), - "mailings.printDocuments" => true, - _ => false, - } +/// User commands that close the shown document: Close. New, Open, restoring a recovered copy, +/// Envelopes, Labels, Finish & Merge and Print Documents open their document beside it (Open and restoring +/// replace only an untouched blank document, which has nothing to lose). +pub(crate) fn discards_document(id: &str, _params: &Value) -> bool { + id == "file.close" } /// Monotonic milliseconds for deadlines, settling and animation. `web_time::Instant` is a real @@ -5682,27 +5994,19 @@ mod tests { path } - /// Surfaces finding 1: Mod+N (and the File › New tiles) replaced unsaved work without asking. + /// Mod+N (and the File › New tiles) open a new document beside unsaved work, asking nothing: + /// nothing is replaced. #[test] - fn new_asks_before_discarding_unsaved_work() { + fn new_opens_beside_unsaved_work() { let mut a = typed(); - a.run("file.new", json!({})).unwrap(); - assert_eq!(prompt(&a), Some("saveChanges")); - assert!(body_text(&a).contains(UNSAVED), "the document is untouched while the prompt is up"); - assert!(a.session.dirty); - - // Cancel: nothing happens. - a.run("ui.saveChanges", json!({"answer": "cancel"})).unwrap(); - assert_eq!(prompt(&a), None); - assert!(body_text(&a).contains(UNSAVED)); - - // Don't Save: the new document replaces it. + let first = a.session.document_id(); a.run("file.new", json!({"template": "letter"})).unwrap(); - a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); assert_eq!(prompt(&a), None); assert!(!body_text(&a).contains(UNSAVED)); assert!(!a.session.dirty); assert!(!body_text(&a).trim().is_empty(), "the template the user picked was used"); + a.run("view.switchWindows", json!({"id": first})).unwrap(); + assert!(body_text(&a).contains(UNSAVED) && a.session.dirty, "the unsaved document is still open"); } #[test] @@ -5726,9 +6030,8 @@ mod tests { assert!(a.execute("file.open", json!({"path": path.to_string_lossy()})).is_err(), "a script gets the error"); assert_eq!(prompt(&a), None); a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap(); - a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); assert_eq!(prompt(&a), Some("password")); - assert!(body_text(&a).contains(UNSAVED), "nothing is replaced until the password fits"); + assert!(body_text(&a).contains(UNSAVED), "nothing opens until the password fits"); let state = serde_json::to_string(&a.dialog).unwrap(); assert!(state.contains("locked.docx") && !state.contains("sesame")); @@ -5741,20 +6044,24 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - /// Open (after the file is picked), recent files and dropped files all go through `file.open`. + /// Open (after the file is picked), recent files and dropped files all go through `file.open`: + /// the file opens beside unsaved work, asking nothing, and opening it again shows it. #[test] - fn open_asks_before_discarding_unsaved_work() { + fn open_opens_beside_unsaved_work() { let dir = scratch("open"); let path = docx_on_disk(&dir, "Signed contract"); let mut a = typed(); + let first = a.session.document_id(); a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap(); - assert_eq!(prompt(&a), Some("saveChanges")); - assert!(body_text(&a).contains(UNSAVED)); - assert_eq!(a.session.path, None); - - a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); + assert_eq!(prompt(&a), None); assert!(body_text(&a).contains("Signed contract")); assert_eq!(a.session.path.as_deref(), Some(path.as_path())); + let opened = a.session.document_id(); + a.run("view.switchWindows", json!({"id": first})).unwrap(); + assert!(body_text(&a).contains(UNSAVED) && a.session.dirty); + a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(a.session.document_id(), opened, "an open file is shown, not opened twice"); + assert_eq!(a.session.documents().len(), 2); let _ = std::fs::remove_dir_all(&dir); } @@ -6476,15 +6783,15 @@ mod tests { } } - /// A prompt about document A outlived an agent replacing A with B, and Don't Save then ran - /// the pending New against B, throwing away B's edits under a question naming A. + /// A prompt about document A outlived an agent showing B instead, and Don't Save then ran + /// the pending Close against B, throwing away B's edits under a question naming A. #[test] fn a_prompt_is_dropped_when_its_document_is_replaced() { let dir = scratch("stale"); let path = docx_on_disk(&dir, "Agent document"); let ctx = egui::Context::default(); let mut a = typed(); - a.run("file.new", json!({})).unwrap(); + a.run("file.close", json!({})).unwrap(); assert_eq!(prompt(&a), Some("saveChanges")); let asked = a.dialog.clone(); for command in [ @@ -6836,6 +7143,70 @@ mod tests { assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "Kilns"); } + /// PR #4241: Ctrl/Cmd+Shift+C copies the selection's formatting and Ctrl/Cmd+Shift+V pastes it + /// onto another selection, as in Word — not the text. egui 0.37 delivers those chords as keys + /// (only Cmd/Ctrl without extra modifiers become copy/paste events), so the registry's + /// `Mod+Shift+C`/`Mod+Shift+V` commands must run. + #[test] + fn ctrl_shift_c_and_v_copy_and_paste_formatting_not_text() { + // A native event carries ⌘ (mac_cmd and command) on macOS, Ctrl (ctrl and command) + // elsewhere; COMMAND+SHIFT is the logical chord both platforms resolve. + let native = if cfg!(target_os = "macos") { + egui::Modifiers::MAC_CMD | egui::Modifiers::COMMAND | egui::Modifiers::SHIFT + } else { + egui::Modifiers::CTRL | egui::Modifiers::COMMAND | egui::Modifiers::SHIFT + }; + let copied = |h: &egui_kittest::Harness| -> Vec { + h.output() + .platform_output + .commands + .iter() + .filter_map(|c| match c { + egui::OutputCommand::CopyText(t) => Some(t.clone()), + _ => None, + }) + .collect() + }; + let bold = |h: &mut egui_kittest::Harness| -> Option { + h.state_mut().run("format.state", json!({})).unwrap().get("bold").and_then(Value::as_bool) + }; + for modifiers in [egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, native] { + let mut a = app(); + a.run("text.insert", json!({"text": "Bold plain"})).unwrap(); + a.run("select.text", json!({"text": "Bold"})).unwrap(); + a.run("format.bold", json!({})).unwrap(); + a.run("select.text", json!({"text": "plain"})).unwrap(); + a.run("edit.copy", json!({})).unwrap(); + let clipboard_entries = a.session.clip_history.len(); + assert_eq!(a.session.clipboard_text, "plain"); + assert!(clipboard_entries > 0); + a.run("select.text", json!({"text": "Bold"})).unwrap(); + let mut h = harness(a); + h.run_steps(6); + assert!(h.state().canvas.focused, "the canvas's key handler has the keyboard"); + assert_eq!(bold(&mut h), Some(true), "the source selection is bold"); + let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + h.event(key(egui::Key::C, modifiers)); + h.step(); + assert!(h.state().session.painter.is_some(), "{modifiers:?}: Copy Formatting picked up the selection's formatting"); + assert_eq!(h.state().session.clip_history.len(), clipboard_entries, "{modifiers:?}: Copy Formatting preserved clipboard history"); + assert_eq!(h.state().session.clipboard_text, "plain", "{modifiers:?}: the existing clipboard text was preserved"); + assert!(copied(&h).is_empty(), "{modifiers:?}: no text reached the system clipboard: {:?}", copied(&h)); + assert_eq!(body_text(h.state()), "Bold plain", "{modifiers:?}: copying formatting left the document text alone"); + + h.state_mut().run("select.text", json!({"text": "plain"})).unwrap(); + assert_eq!(bold(&mut h), Some(false), "the target selection is plain before the paste"); + h.event(key(egui::Key::V, modifiers)); + h.step(); + assert_eq!(bold(&mut h), Some(true), "{modifiers:?}: Paste Formatting made the target bold"); + assert!(h.state().session.painter.is_none(), "{modifiers:?}: the non-sticky painter is used up"); + assert_eq!(body_text(h.state()), "Bold plain", "{modifiers:?}: pasting formatting left the document text alone"); + assert_eq!(h.state().session.clip_history.len(), clipboard_entries, "{modifiers:?}: Paste Formatting preserved clipboard history"); + assert_eq!(h.state().session.clipboard_text, "plain", "{modifiers:?}: Paste Formatting preserved the clipboard text"); + assert!(copied(&h).is_empty(), "{modifiers:?}: pasting formatting put no text on the system clipboard: {:?}", copied(&h)); + } + } + /// #1669: with Shift held, Backspace still erases (#707) and Delete cuts the selection, doing /// nothing without one, as in Word for Windows. (Windows sends Shift+Delete as a Cut; macOS and /// Linux send the key, which reached no command.) @@ -6926,7 +7297,8 @@ mod tests { assert!(!closes(&h), "Ctrl+Q doesn't close the window"); h.event(key(egui::Key::Q, egui::Modifiers::MAC_CMD | egui::Modifiers::COMMAND)); h.step(); - assert!(closes(&h), "⌘Q still quits on the Mac"); + // Quitting asks about the typed text first, in the document's window. + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges"), "⌘Q still quits on the Mac"); } /// #3424: Pressing Ctrl+Alt+Q (German shortcut for @, or AltGr+Q) types '@' and does not @@ -7043,11 +7415,11 @@ mod tests { let mut a = app(); a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap(); a.run("text.insert", json!({"text": "Revised "})).unwrap(); - a.run("file.new", json!({})).unwrap(); + a.run("file.close", json!({})).unwrap(); assert_eq!(prompt(&a), Some("saveChanges")); a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); assert_eq!(prompt(&a), None); - assert_eq!(a.session.path, None, "the new document replaced the saved one"); + assert!(!a.has_document(), "the saved document closed"); let saved = wordcraft_engine::io::open_path(&path).unwrap(); assert!(saved.plain_text(wordcraft_doc::StoryRef::Body).contains("Revised Draft")); let _ = std::fs::remove_dir_all(&dir); @@ -7062,10 +7434,10 @@ mod tests { let picked = dir.join(format!("novel.{ext}")).to_string_lossy().to_string(); let mut a = typed(); a.services.pick_save = Some(Box::new(move |_| Some(picked.clone()))); - a.run("file.new", json!({})).unwrap(); + a.run("file.close", json!({})).unwrap(); a.status_msg = None; let r = a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); - assert_eq!(r["done"], false, "{ext}: the New was cancelled"); + assert_eq!(r["done"], false, "{ext}: the Close was cancelled"); assert!(dir.join(format!("novel.{ext}")).exists(), "{ext}: the copy was written"); assert_eq!(prompt(&a), None); assert!(body_text(&a).contains(UNSAVED), "{ext}: the document is kept"); @@ -7264,9 +7636,9 @@ mod tests { assert!(a.execute("file.save", json!({})).is_err()); assert!(a.session.dirty); - a.run("file.new", json!({})).unwrap(); + a.run("file.close", json!({})).unwrap(); a.status_msg = None; - // A failed save is an error, not a quiet Cancel (#1445); the New doesn't run. + // A failed save is an error, not a quiet Cancel (#1445); the Close doesn't run. let err = a.run("ui.saveChanges", json!({"answer": "save"})).unwrap_err(); assert!(err.contains("still open") && err.contains("blocked by the browser"), "{err}"); assert!(body_text(&a).contains(UNSAVED)); @@ -7291,21 +7663,21 @@ mod tests { (a, saves, last) } - /// #1315: Save in the prompt before New waits for the picker: New runs only once the file + /// #1315: Save in the prompt before Close waits for the picker: Close runs only once the file /// is written and closed. #[test] - fn web_save_before_new_waits_for_the_picker_to_finish() { + fn web_save_before_close_waits_for_the_picker_to_finish() { let (mut a, saves, last) = web_picker_app(); - a.run("file.new", json!({})).unwrap(); + a.run("file.close", json!({})).unwrap(); assert_eq!(prompt(&a), Some("saveChanges")); let r = a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); assert_eq!(r["pending"], "save", "{r}"); assert_eq!(saves.pending(), 1); a.settle_async_saves(); - assert!(body_text(&a).contains(UNSAVED), "nothing is replaced while the picker is open"); + assert!(body_text(&a).contains(UNSAVED), "nothing closes while the picker is open"); saves.finish(last.get(), async_saves::SaveOutcome::Saved); a.settle_async_saves(); - assert!(!body_text(&a).contains(UNSAVED), "the New ran once the save finished"); + assert!(!body_text(&a).contains(UNSAVED), "the Close ran once the save finished"); assert!(!a.session.dirty); assert_eq!(prompt(&a), None); } @@ -7313,12 +7685,12 @@ mod tests { /// #1315: cancelling the picker, or a write/close that fails, keeps the document: its text, /// its undo, still unsaved, and the prompt back with the reason. Nothing is replaced. #[test] - fn web_save_before_new_that_is_cancelled_or_fails_keeps_the_document() { + fn web_save_before_close_that_is_cancelled_or_fails_keeps_the_document() { for (outcome, reason) in [(async_saves::SaveOutcome::Cancelled, "cancel"), (async_saves::SaveOutcome::Failed("the disk is full".into()), "the disk is full")] { let (mut a, saves, last) = web_picker_app(); - a.run("file.new", json!({})).unwrap(); + a.run("file.close", json!({})).unwrap(); a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); a.status_msg = None; saves.finish(last.get(), outcome.clone()); @@ -7338,11 +7710,11 @@ mod tests { /// #1315 review: the prompt's Save is in the picker (ticket 1), the user edits and saves /// again (ticket 2, which marks the document saved at once). Ticket 1 ending doesn't let the - /// held-back New replace the document while ticket 2 hasn't written its edits. + /// held-back Close close the document while ticket 2 hasn't written its edits. #[test] fn web_overlapping_saves_hold_the_replacement() { let (mut a, saves, last) = web_picker_app(); - a.run("file.new", json!({})).unwrap(); + a.run("file.close", json!({})).unwrap(); a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); let first = last.get(); a.run("text.insert", json!({"text": "Later edit "})).unwrap(); @@ -7355,11 +7727,11 @@ mod tests { } /// #1315 review: edited while the picker was open, then the save ends: those edits aren't - /// in the file, so the prompt asks again instead of running New. + /// in the file, so the prompt asks again instead of running Close. #[test] fn web_save_that_ends_after_an_edit_asks_again() { let (mut a, saves, last) = web_picker_app(); - a.run("file.new", json!({})).unwrap(); + a.run("file.close", json!({})).unwrap(); a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); a.run("text.insert", json!({"text": "Later edit "})).unwrap(); saves.finish(last.get(), async_saves::SaveOutcome::Saved); @@ -7370,12 +7742,12 @@ mod tests { assert_eq!(prompt_message(&a).as_deref(), Some("")); } - /// #1315 review: the status bar says New/Open is waiting for the save; a cancelled picker + /// #1315 review: the status bar says Close is waiting for the save; a cancelled picker /// says it was cancelled, not that saving failed. #[test] fn web_save_prompt_says_it_waits_and_that_a_cancel_was_a_cancel() { let (mut a, saves, last) = web_picker_app(); - a.run("file.new", json!({})).unwrap(); + a.run("file.close", json!({})).unwrap(); a.status_msg = None; a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); let status = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default(); @@ -7387,62 +7759,61 @@ mod tests { assert!(!message.contains("Couldn't save"), "a cancel isn't a failure: {message}"); } - /// #1315 review: a document dropped or picked on the web (`file.open` with its bytes) waits - /// for the save too, then opens. + /// #1315 review: a document dropped or picked on the web (`file.open` with its bytes) opens + /// beside one whose save is still in the picker, asking nothing. #[test] - fn web_save_before_open_opens_the_file_once_saved() { + fn web_open_opens_beside_a_save_in_flight() { let dir = scratch("web-open-after-save"); let bytes = std::fs::read(docx_on_disk(&dir, "Picked document")).unwrap(); - let (mut a, saves, last) = web_picker_app(); + let (mut a, _saves, _last) = web_picker_app(); + let first = a.session.document_id(); + a.execute("file.save", json!({})).unwrap(); let data = wordcraft_engine::cmd::insert::base64_encode(&bytes); a.run("file.open", json!({"path": "picked.docx", "data": data})).unwrap(); - assert_eq!(prompt(&a), Some("saveChanges")); - a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); - a.settle_async_saves(); - assert!(body_text(&a).contains(UNSAVED), "nothing opened while the picker is open"); - saves.finish(last.get(), async_saves::SaveOutcome::Saved); - a.settle_async_saves(); + assert_eq!(prompt(&a), None); assert!(body_text(&a).contains("Picked document"), "the picked file opened: {:?}", body_text(&a)); + a.run("view.switchWindows", json!({"id": first})).unwrap(); + assert!(body_text(&a).contains(UNSAVED), "the first document is still open"); let _ = std::fs::remove_dir_all(&dir); } - /// #1315: while the picker save is in flight the document's save isn't confirmed, so New - /// asks again instead of replacing it. + /// #1315: while the picker save is in flight the document's save isn't confirmed, so Close + /// asks again instead of closing it. #[test] - fn web_new_while_a_picker_save_is_in_flight_asks_first() { + fn web_close_while_a_picker_save_is_in_flight_asks_first() { let (mut a, _saves, _last) = web_picker_app(); a.execute("file.save", json!({})).unwrap(); - a.run("file.new", json!({})).unwrap(); + a.run("file.close", json!({})).unwrap(); assert_eq!(prompt(&a), Some("saveChanges")); assert!(body_text(&a).contains(UNSAVED), "nothing was replaced"); } - /// #1315: a save that ends after the document was replaced neither runs the held-back New on - /// the new document nor marks it unsaved. + /// #1315: a save that ends after another document is shown neither runs the held-back Close + /// on that document nor marks it unsaved. #[test] fn web_picker_save_ending_after_the_document_changed_leaves_the_new_one_alone() { for outcome in [async_saves::SaveOutcome::Saved, async_saves::SaveOutcome::Failed("gone".into())] { let (mut a, saves, last) = web_picker_app(); - a.run("file.new", json!({})).unwrap(); + a.run("file.close", json!({})).unwrap(); a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); - // Another document replaces it (an agent's or a script's command) while the picker is open. + // Another document is shown (an agent's or a script's command) while the picker is open. a.execute("file.new", json!({})).unwrap(); a.execute("text.insert", json!({"text": "Second document"})).unwrap(); a.session.dirty = false; saves.finish(last.get(), outcome.clone()); a.settle_async_saves(); - assert!(body_text(&a).contains("Second document"), "{outcome:?}: the held-back New didn't run on it"); + assert!(body_text(&a).contains("Second document"), "{outcome:?}: the held-back Close didn't run on it"); assert!(!a.session.dirty, "{outcome:?}: the new document isn't marked unsaved"); assert_eq!(prompt(&a), None, "{outcome:?}"); } } - /// A new document has no path: Save goes through Save As, and cancelling that cancels the New. + /// A new document has no path: Save goes through Save As, and cancelling that cancels the Close. #[test] fn save_as_cancelled_keeps_the_document() { let mut a = typed(); a.services.pick_save = Some(Box::new(|_| None)); - a.run("file.new", json!({})).unwrap(); + a.run("file.close", json!({})).unwrap(); a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); assert_eq!(prompt(&a), None); assert!(body_text(&a).contains(UNSAVED)); @@ -7560,35 +7931,28 @@ mod tests { } } - /// Envelopes, Labels and Finish & Merge replace the document with a new one, like New, but - /// the ribbon ran them at once and the unsaved document was gone without a question. + /// Envelopes, Labels and Finish & Merge open their result as a new document beside the + /// unsaved one, like New, asking nothing. #[test] - fn mailings_ask_before_replacing_unsaved_work() { + fn mailings_open_beside_unsaved_work() { for id in MAILINGS { let mut a = typed(); a.run("mailings.recipients", json!({"csv": "Name\nAda"})).unwrap(); let document = a.session.document_id(); a.run(id, mailing(id)).unwrap(); - assert_eq!(prompt(&a), Some("saveChanges"), "{id}"); - assert_eq!(a.session.document_id(), document, "{id}: untouched while the prompt is up"); - - a.run("ui.saveChanges", json!({"answer": "cancel"})).unwrap(); - assert_eq!(prompt(&a), None); - assert_eq!(a.session.document_id(), document, "{id}: Cancel keeps the document"); - assert!(body_text(&a).contains(UNSAVED)); - assert!(a.session.dirty); - - a.run(id, mailing(id)).unwrap(); - a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); - assert_eq!(prompt(&a), None); - assert_ne!(a.session.document_id(), document, "{id}: Don't Save makes the new document"); + assert_eq!(prompt(&a), None, "{id}"); + assert_ne!(a.session.document_id(), document, "{id}: the result is a new document"); assert_eq!(a.session.path, None); + a.run("view.switchWindows", json!({"id": document})).unwrap(); + assert!(body_text(&a).contains(UNSAVED) && a.session.dirty, "{id}: the unsaved document is still open"); } - // A `path` that isn't text merges into a new document, like no path at all. + // A `path` that isn't text merges into a new document beside this one, like no path at all. let mut a = typed(); a.run("mailings.recipients", json!({"csv": "Name\nAda"})).unwrap(); + let document = a.session.document_id(); a.run("mailings.finish", json!({"path": null, "records": "all"})).unwrap(); - assert_eq!(prompt(&a), Some("saveChanges")); + assert_eq!(prompt(&a), None); + assert_ne!(a.session.document_id(), document); } /// A clean document is replaced straight away, like New; a merge written to a file @@ -7617,7 +7981,7 @@ mod tests { } /// The result is a new, untitled document: Save, AutoSave and the title never point at the - /// file the original came from, even after Save in the prompt wrote that file. + /// file the original came from. #[test] fn mailings_results_never_save_over_the_original() { for id in MAILINGS { @@ -7630,14 +7994,9 @@ mod tests { assert!(a.autosaves()); a.run("mailings.recipients", json!({"csv": "Name\nAda"})).unwrap(); a.run("text.insert", json!({"text": "Again "})).unwrap(); - a.run(id, mailing(id)).unwrap(); - a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); - assert_eq!(prompt(&a), None); let saved = std::fs::read(&path).unwrap(); - assert!( - wordcraft_engine::io::open_path(&path).unwrap().plain_text(wordcraft_doc::StoryRef::Body).contains("Revised Again Signed"), - "{id}: Save wrote the document first" - ); + a.run(id, mailing(id)).unwrap(); + assert_eq!(prompt(&a), None); assert_eq!(a.session.path, None, "{id}"); assert!(!a.saved_here() && !a.autosaves(), "{id}: AutoSave doesn't cover the result"); @@ -7670,26 +8029,28 @@ mod tests { } } - /// Review finding: with undo kept across the swap, an agent's Undo brought document A back - /// under the id of the mailing result B while a prompt about B was up, and Don't Save then - /// threw away A. The history starts afresh instead, so the prompt only ever discards B. + /// Review finding: an agent's Undo while a prompt about the mailing result B was up must not + /// bring document A back under B, where Don't Save would throw A away. The result is its + /// own document: Undo stays in B, and Don't Save closes B and shows A, unsaved work intact. #[test] fn undo_after_a_mailing_cannot_redirect_a_pending_prompt() { for id in MAILINGS { let mut a = typed(); a.execute("mailings.recipients", json!({"csv": "Name\nAda"})).unwrap(); + let first = a.session.document_id(); a.execute(id, json!({})).unwrap(); - let result = body_text(&a); let b = a.session.document_id(); - assert!(a.session.dirty, "{id}: the result is unsaved"); - a.run("file.new", json!({})).unwrap(); + assert_ne!(b, first, "{id}"); + a.run("text.insert", json!({"text": "Edited result "})).unwrap(); + a.run("file.close", json!({})).unwrap(); assert_eq!(prompt(&a), Some("saveChanges"), "{id}"); a.execute("edit.undo", json!({})).unwrap(); assert_eq!(a.session.document_id(), b, "{id}"); - assert_eq!(body_text(&a), result, "{id}: nothing from before the mailing comes back"); a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); - assert_ne!(a.session.document_id(), b, "{id}: Don't Save discarded the document it asked about"); + assert!(!a.session.is_open(b), "{id}: Don't Save closed the document it asked about"); + assert_eq!(a.session.document_id(), first, "{id}"); + assert!(body_text(&a).contains(UNSAVED) && a.session.dirty, "{id}"); } } @@ -7921,6 +8282,125 @@ mod tests { assert!(!old.session.view.nav_pane, "no saved value: closed"); } + /// Task panes: two open panes on one edge sit side by side, the first outermost; a pane's × + /// runs its command; the Styles pane's place and float rect survive a restart (#1756). + #[test] + fn task_panes_share_an_edge_close_and_keep_the_styles_place() { + use egui_kittest::kittest::Queryable; + let bars = |h: &egui_kittest::Harness<'_, WordApp>| { + let mut bars: Vec = h.get_all_by_label_contains("Move Pane: ").map(|n| n.rect()).collect(); + bars.sort_by(|a, b| a.left().total_cmp(&b.left())); + bars + }; + // A title bar's ▾ and × sit right of the bar, on its row. + fn click_button(h: &egui_kittest::Harness<'_, WordApp>, bar: egui::Rect, label: &str) { + let on_row = |r: egui::Rect| r.center().y > bar.top() && r.center().y < bar.bottom() && r.left() >= bar.right() - 1.0; + let node = h.get_all_by_label(label).filter(|n| on_row(n.rect())).min_by(|a, b| a.rect().left().total_cmp(&b.rect().left())); + node.unwrap_or_else(|| panic!("no {label} beside the title bar")).click(); + } + let mut a = app(); + a.run("view.stylesPane", json!({"value": true})).unwrap(); + a.run("view.commentsPane", json!({"value": true})).unwrap(); + let mut h = harness(a); + h.run_steps(4); + let [comments, styles] = bars(&h)[..] else { panic!("two pane title bars") }; + assert!(comments.right() <= styles.left() + 1.0, "side by side"); + assert!(comments.left() > 720.0, "both on the right"); + // Default widths 290 and 250: Styles, opened first, is the outer one. + assert!((comments.width() - styles.width() - 40.0).abs() < 2.0, "Styles is outermost: {comments:?} {styles:?}"); + + click_button(&h, styles, "Close"); + h.run_steps(3); + assert!(!h.state().session.view.styles_pane, "× ran view.stylesPane"); + assert!(h.state().session.view.comments_pane); + assert_eq!(bars(&h).len(), 1); + + h.state_mut().run("view.stylesPane", json!({"value": true})).unwrap(); + h.run_steps(3); + let styles = *bars(&h).last().unwrap(); + click_button(&h, styles, "Pane Options"); + h.run_steps(2); + h.get_by_label("Float").click(); + h.run_steps(3); + assert_eq!(h.state().session.view.styles_pane_dock, wordcraft_engine::StylesPaneDock::Floating); + let rect = h.state().ui.styles_pane_float.expect("the float rect is kept"); + + let saved = serde_json::to_vec(&h.state().prefs()).unwrap(); + let mut next = app(); + next.apply_prefs(serde_json::from_slice(&saved).unwrap()); + assert_eq!(next.session.view.styles_pane_dock, wordcraft_engine::StylesPaneDock::Floating); + assert_eq!(next.ui.styles_pane_float, Some(rect)); + + let mut junk = app(); + junk.apply_prefs(serde_json::from_str(r#"{"stylesPaneDock": "top", "stylesPaneFloat": [1, "x", 3, 4], "navPane": true}"#).unwrap()); + assert_eq!(junk.session.view.styles_pane_dock, wordcraft_engine::StylesPaneDock::Right, "junk reads as the default"); + assert_eq!(junk.ui.styles_pane_float, None); + assert!(junk.session.view.nav_pane, "the rest of the file still loads"); + } + + /// A pointer drag on a pane's resize edge leaves no keyboard focus behind, so Ctrl and + /// F-key shortcuts run right after it, docked or floating. A resize edge focused from the + /// keyboard takes the arrow keys until Escape (#1756). + #[test] + fn shortcuts_run_after_a_pane_resize() { + use egui_kittest::kittest::Queryable; + type H = egui_kittest::Harness<'static, WordApp>; + fn key(h: &mut H, key: egui::Key, modifiers: egui::Modifiers) { + h.event(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }); + h.run_steps(2); + } + fn drag(h: &mut H, from: egui::Pos2, by: egui::Vec2) { + let button = + |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + h.event(egui::Event::PointerMoved(from)); + h.step(); + h.event(button(from, true)); + h.step(); + for t in [0.5, 1.0] { + h.event(egui::Event::PointerMoved(from + by * t)); + h.step(); + } + h.event(button(from + by, false)); + h.run_steps(2); + } + // Ctrl+H opens Replace; Escape closes it again. + fn replace_opens(h: &mut H) -> bool { + key(h, egui::Key::H, egui::Modifiers::COMMAND); + let opened = h.state().dialog.as_ref().map(|d| d.name()) == Some("replace"); + key(h, egui::Key::Escape, egui::Modifiers::NONE); + opened && h.state().dialog.is_none() + } + let mut a = app(); + a.run("view.stylesPane", json!({"value": true})).unwrap(); + let mut h = harness(a); + h.run_steps(4); + + let edge = h.get_by_label("Resize Pane").rect(); + // On the pane's side of the edge: the document's scroll bar lies under its outer half. + drag(&mut h, edge.center() + egui::vec2(1.5, 0.0), egui::vec2(-40.0, 0.0)); + let width = h.state().ui.styles_pane_width.unwrap_or_default(); + assert!((width - 290.0).abs() <= 2.0, "the drag widened the docked pane: {width}"); + assert!(replace_opens(&mut h), "Ctrl+H runs after the drag"); + + h.get_by_label("Resize Pane").focus(); + h.run_steps(2); + key(&mut h, egui::Key::ArrowLeft, egui::Modifiers::NONE); + assert!(h.state().ui.styles_pane_width.is_some_and(|w| w > width), "arrow keys move the focused edge"); + key(&mut h, egui::Key::Escape, egui::Modifiers::NONE); + assert_eq!(h.ctx.memory(|m| m.focused()), None, "Escape leaves the edge"); + assert!(replace_opens(&mut h), "Ctrl+H runs after Escape"); + + h.state_mut().run("view.stylesPaneDock", json!({"dock": "float"})).unwrap(); + h.run_steps(3); + // The bottom-right grip. + let corner = |r: &egui::Rect| r.max.x + r.max.y; + let grip = h.get_all_by_label("Resize Pane").map(|n| n.rect()).max_by(|a, b| corner(a).total_cmp(&corner(b))).unwrap(); + let before = h.state().ui.styles_pane_float; + drag(&mut h, grip.center(), egui::vec2(30.0, 30.0)); + assert_ne!(h.state().ui.styles_pane_float, before, "the drag resized the floating pane"); + assert!(replace_opens(&mut h), "Ctrl+H runs after the floating pane's resize"); + } + #[test] fn check_spelling_survives_restart() { // #1792: unchecking Check spelling as you type was forgotten on restart. @@ -9097,10 +9577,11 @@ mod tests { assert!(!a.recipient_list_pending); inbox.lock().unwrap().push(("notes.txt".into(), b"A plain text document.\nAnother line.\n".to_vec())); a.drain_inbox(); - assert_eq!(prompt(&a), Some("saveChanges"), "the TXT opens like any document"); - a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); - assert_eq!(a.session.merge.headers, vec!["Name", "City"], "the recipients stay"); + assert_eq!(prompt(&a), None, "the TXT opens like any document, beside the first"); assert!(body_text(&a).contains("A plain text document."), "the TXT opened as a document"); + let first = a.session.documents().first().map(|d| d.id).unwrap(); + a.run("view.switchWindows", json!({"id": first})).unwrap(); + assert_eq!(a.session.merge.headers, vec!["Name", "City"], "the recipients stay with their document"); // A cancelled picker leaves nothing pending once the user does something else. a.run("ui.openRecipientList", json!({})).unwrap(); a.run("format.bold", json!({})).unwrap(); @@ -9314,7 +9795,8 @@ mod tests { let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; h.event(egui::Event::Text("Kilns fire clay. Kilns cool.".into())); h.step(); - let nav_query = |h: &egui_kittest::Harness| h.ctx.data(|d| d.get_temp::(egui::Id::unique("nav_query"))).unwrap_or_default(); + let nav_query = + |h: &egui_kittest::Harness| h.ctx.data(|d| d.get_temp::(h.state().window_id("nav_query"))).unwrap_or_default(); // Ctrl+F: the pane, and typing goes into its search box. h.event(key(egui::Key::F, egui::Modifiers::COMMAND)); h.step(); @@ -9499,7 +9981,7 @@ mod tests { let mut raw = egui::RawInput { screen_rect: Some(rect), ..Default::default() }; raw.viewports.get_mut(&egui::ViewportId::ROOT).unwrap().native_pixels_per_point = Some(1.5); let surface = control::SurfaceGeometry::from_raw(&raw, &ctx).expect("screen rect fallback"); - let geometry = surface.with_document(7); + let geometry = surface.with_target(egui::ViewportId::ROOT, 7); assert_eq!(geometry.expected_size(), Some([480, 300])); assert_eq!(geometry.expected_bytes(), Some(480 * 300 * 4)); } @@ -9916,13 +10398,14 @@ mod tests { h.step(); assert!(h.state().session.view.focus_mode); assert!(!shown(&h, "Ruler") && !shown(&h, "Focus"), "the ribbon is hidden"); - assert!(!shown(&h, "Navigation"), "and so is the Navigation pane"); + // The pane's title bar: the only pane open is Navigation. + assert!(!shown(&h, "Move Pane: Navigation"), "and so is the Navigation pane"); h.event(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }); h.step(); h.step(); assert!(!h.state().session.view.focus_mode, "Escape leaves Focus"); assert!(shown(&h, "Ruler"), "and the ribbon is back"); - assert!(shown(&h, "Navigation"), "with the pane"); + assert!(shown(&h, "Move Pane: Navigation"), "with the pane"); // Immersive Reader is Read Mode, not Focus: its status bar stays. h.state_mut().run("view.immersive", json!({})).unwrap(); h.step(); diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index e4b34a8784..00c31190ef 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -1,129 +1,456 @@ -//! Side panes: Navigation (headings, pages, search results), Clipboard, Styles, Style Inspector, -//! Accessibility and Alt Text (see `a11y_pane`), Format Shape (see `format_shape`), Restrict Editing ([`crate::dialogs_review`]), Comments, -//! and the SmartArt Text Pane. +//! Task panes: Navigation (headings, pages, search results), Clipboard, Styles, Style Inspector, +//! Accessibility and Alt Text (see `a11y_pane`), Format Shape (see `format_shape`), Restrict Editing +//! ([`crate::dialogs_review`]), Comments, and the SmartArt Text Pane. `craft_ui::panes` draws them +//! around the document, with their title bars, menus and resize edges; the session's view flags say +//! which are open. use std::collections::BTreeMap; +use craft_ui::panes::{Edge, PaneContent, PaneHost, PaneLabels, PaneRequest, PaneState, Place, SidePolicy}; use egui::{Stroke, Ui, vec2}; use serde_json::{Value, json}; use wordcraft_doc::numbering::{Counters, LevelSuffix}; use wordcraft_doc::section::NumFormat; use wordcraft_doc::{Document, Path, Pos, StoryRef}; +use wordcraft_engine::StylesPaneDock; use crate::WordApp; use crate::theme::{Tokens, regular, semibold}; -pub fn show(app: &mut WordApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - // SmartArt Design › Text Pane, while a SmartArt graphic WordCraft can edit is selected. - if app.session.view.smart_art_pane && wordcraft_engine::cmd::smart_art::selected_smart_art(&app.session).is_some() { - egui::Panel::left("smart_art_pane") - .default_size(250.0) - .resizable(true) - .frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))) - .show(ui, |ui| { - if header(ui, "Text Pane") { - let _ = app.run("smartArt.textPane", json!({"value": false})); - return; - } - crate::dialogs_smart_art::text_pane(app, ui); - }); +/// The task panes, in their default order: each edge's first pane is outermost. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Pane { + /// SmartArt Design › Text Pane, while a SmartArt graphic WordCraft can edit is selected. + SmartArtText, + Navigation, + Clipboard, + Styles, + StyleInspector, + Accessibility, + AltText, + FormatShape, + RestrictEditing, + Comments, +} + +impl Pane { + fn title(self) -> &'static str { + match self { + Self::SmartArtText => "Text Pane", + Self::Navigation => "Navigation", + Self::Clipboard => "Clipboard", + Self::Styles => "Styles", + Self::StyleInspector => "Style Inspector", + Self::Accessibility => "Accessibility", + Self::AltText => "Alt Text", + Self::FormatShape => "Format Shape", + Self::RestrictEditing => "Restrict Editing", + Self::Comments => "Comments", + } } - if app.session.view.nav_pane { - egui::Panel::left("nav_pane") - .default_size(260.0) - .resizable(true) - .frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))) - .show(ui, |ui| nav(app, ui)); + + fn default_state(self) -> PaneState { + let (edge, width) = match self { + Self::SmartArtText => (Edge::Start, 250.0), + Self::Navigation => (Edge::Start, 260.0), + Self::Clipboard => (Edge::Start, 250.0), + Self::Styles => (Edge::End, 250.0), + Self::StyleInspector => (Edge::End, 240.0), + Self::Accessibility => (Edge::End, 280.0), + Self::AltText => (Edge::End, 260.0), + Self::FormatShape => (Edge::End, 280.0), + Self::RestrictEditing => (Edge::End, 270.0), + Self::Comments => (Edge::End, 290.0), + }; + PaneState::docked(edge, width) } - if app.session.view.clipboard_pane { - egui::Panel::left("clipboard_pane") - .default_size(250.0) - .resizable(true) - .frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))) - .show(ui, |ui| clipboard(app, ui)); + + /// The pane's key in `ui.json`'s `panePlaces`. + pub fn key(self) -> &'static str { + match self { + Self::SmartArtText => "smartArtText", + Self::Navigation => "navigation", + Self::Clipboard => "clipboard", + Self::Styles => "styles", + Self::StyleInspector => "styleInspector", + Self::Accessibility => "accessibility", + Self::AltText => "altText", + Self::FormatShape => "formatShape", + Self::RestrictEditing => "restrictEditing", + Self::Comments => "comments", + } } - if app.session.view.styles_pane { - egui::Panel::right("styles_pane") - .default_size(250.0) - .resizable(true) - .frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))) - .show(ui, |ui| styles(app, ui)); + + const ALL: [Self; 10] = [ + Self::SmartArtText, + Self::Navigation, + Self::Clipboard, + Self::Styles, + Self::StyleInspector, + Self::Accessibility, + Self::AltText, + Self::FormatShape, + Self::RestrictEditing, + Self::Comments, + ]; + + fn from_key(key: &str) -> Option { + Self::ALL.into_iter().find(|pane| pane.key() == key) } - if app.session.view.style_inspector { - egui::Panel::right("style_inspector") - .default_size(240.0) - .resizable(true) - .frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))) - .show(ui, |ui| inspector(app, ui)); - } - if app.session.view.accessibility_pane { - egui::Panel::right("accessibility_pane") - .default_size(280.0) - .resizable(true) - .frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))) - .show(ui, |ui| crate::a11y_pane::accessibility(app, ui)); - } - if app.session.view.alt_text_pane { - egui::Panel::right("alt_text_pane") - .default_size(260.0) - .resizable(true) - .frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))) - .show(ui, |ui| crate::a11y_pane::alt_text(app, ui)); - } - if !app.session.view.format_shape_pane { - crate::format_shape::forget_drag(ui.ctx()); - } else { - egui::Panel::right("format_shape_pane") - .default_size(280.0) - .resizable(true) - .frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))) - .show(ui, |ui| egui::ScrollArea::vertical().show(ui, |ui| crate::format_shape::pane(app, ui))); - } - if app.restrict.open { - egui::Panel::right("restrict_pane") - .default_size(270.0) - .resizable(true) - .frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))) - .show(ui, |ui| { - if header(ui, "Restrict Editing") { - app.restrict.open = false; - return; - } - crate::dialogs_review::restrict_pane(app, ui); - }); - } - if app.session.view.comments_pane { - egui::Panel::right("comments_pane") - .default_size(290.0) - .resizable(true) - .frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))) - .show(ui, |ui| comments(app, ui)); + + /// The command that closes the pane; Restrict Editing has none and closes in [`close`]. + fn close_command(self) -> Option<&'static str> { + match self { + Self::SmartArtText => Some("smartArt.textPane"), + Self::Navigation => Some("view.navigationPane"), + Self::Clipboard => Some("edit.clipboardPane"), + Self::Styles => Some("view.stylesPane"), + Self::StyleInspector => Some("styles.inspector"), + Self::Accessibility => Some("view.accessibilityPane"), + Self::AltText => Some("view.altTextPane"), + Self::FormatShape => Some("view.formatShapePane"), + Self::Comments => Some("view.commentsPane"), + Self::RestrictEditing => None, + } } } -pub(crate) fn header(ui: &mut Ui, title: &str) -> bool { - let mut close = false; - ui.horizontal(|ui| { - ui.label(egui::RichText::new(tl!(title)).font(semibold(15.0))); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - // Drawn, not the "✕" character: the interface fonts have no glyph for it. - if crate::widgets::icon_button(ui, "close", tl!("Close")).clicked() { - close = true; +/// Every pane in the order the user left them (`paneOrder` in `ui.json`), then the panes that +/// list lacks in their default order. Each edge shows its panes in this order, the first outermost. +fn order(app: &WordApp) -> Vec { + let mut order: Vec = app.ui.pane_order.iter().filter_map(|key| Pane::from_key(key)).collect(); + for pane in Pane::ALL { + if !order.contains(&pane) { + order.push(pane); + } + } + order +} + +/// Put `pane` directly before `before` in the order, outside it on their edge (`None`: after +/// every pane). +fn move_before(app: &mut WordApp, pane: Pane, before: Option) { + let mut order = order(app); + order.retain(|p| *p != pane); + let at = before.and_then(|b| order.iter().position(|p| *p == b)).unwrap_or(order.len()); + order.insert(at, pane); + app.ui.pane_order = order.iter().map(|p| p.key().to_string()).collect(); +} + +/// `paneOrder` as saved: unknown and repeated keys are dropped, not the whole file. +pub(crate) fn lenient_order<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + let v = ::deserialize(d)?; + let mut keys: Vec = Vec::new(); + for key in v.as_array().map(Vec::as_slice).unwrap_or_default().iter().take(Pane::ALL.len() * 4).filter_map(Value::as_str) { + if Pane::from_key(key).is_some() && !keys.iter().any(|k| k == key) { + keys.push(key.to_string()); + } + } + Ok(keys) +} + +/// The panes open now, from the session's view flags, in the user's order. +pub fn open(app: &WordApp) -> Vec { + let v = &app.session.view; + let smart_art = v.smart_art_pane && wordcraft_engine::cmd::smart_art::selected_smart_art(&app.session).is_some(); + let mut open = [ + (Pane::SmartArtText, smart_art), + (Pane::Navigation, v.nav_pane), + (Pane::Clipboard, v.clipboard_pane), + (Pane::Styles, v.styles_pane), + (Pane::StyleInspector, v.style_inspector), + (Pane::Accessibility, v.accessibility_pane), + (Pane::AltText, v.alt_text_pane), + (Pane::FormatShape, v.format_shape_pane), + (Pane::RestrictEditing, app.restrict.open), + (Pane::Comments, v.comments_pane), + ] + .into_iter() + .filter_map(|(pane, shown)| shown.then_some(pane)) + .collect::>(); + let order = order(app); + open.sort_by_key(|pane| order.iter().position(|p| p == pane).unwrap_or(usize::MAX)); + open +} + +/// Where a task pane other than Styles sits, kept in `ui.json` (#1756). Styles keeps its place in +/// `view.stylesPaneDock` and `stylesPaneFloat`. +#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct SavedPane { + /// `None`: the pane's default edge. + #[serde(skip_serializing_if = "Option::is_none")] + pub dock: Option, + /// `[x, y, width, height]` of the floating pane, also kept while it is docked. + #[serde(skip_serializing_if = "Option::is_none")] + pub float: Option<[f32; 4]>, + /// The docked width this run; like the Styles pane's, not kept between runs. + #[serde(skip)] + pub width: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SavedDock { + Left, + Right, + Float, +} + +/// `panePlaces` as saved: unknown panes, places and rects are dropped, not the whole file. +pub(crate) fn lenient_places<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + let v = ::deserialize(d)?; + let Some(map) = v.as_object() else { return Ok(BTreeMap::new()) }; + let dock = |v: &Value| match v.as_str() { + Some("left") => Some(SavedDock::Left), + Some("right") => Some(SavedDock::Right), + Some("float") => Some(SavedDock::Float), + _ => None, + }; + Ok(Pane::ALL + .iter() + .filter(|pane| **pane != Pane::Styles) + .filter_map(|pane| { + let saved = map.get(pane.key())?; + let place = SavedPane { dock: saved.get("dock").and_then(dock), float: saved.get("float").and_then(float_rect), width: None }; + Some((pane.key().to_string(), place)) + }) + .collect()) +} + +/// Four finite numbers as `[x, y, width, height]`; `craft_ui::panes` brings a saved rect back +/// inside the window. +pub(crate) fn float_rect(v: &Value) -> Option<[f32; 4]> { + let [x, y, w, h] = v.as_array().map(Vec::as_slice)? else { return None }; + let n = |v: &Value| v.as_f64().map(|n| n as f32).filter(|n| n.is_finite()); + n(x).zip(n(y)).zip(n(w)).zip(n(h)).map(|(((x, y), w), h)| [x, y, w, h]) +} + +/// A pane's place and size: Styles' from `view.stylesPaneDock` and its own preferences, the +/// others' from `panePlaces`. +fn pane_state(app: &WordApp, pane: Pane) -> PaneState { + if pane == Pane::Styles { + return styles_state(app); + } + let default = pane.default_state(); + let Some(saved) = app.ui.pane_places.get(pane.key()) else { return default }; + let place = match saved.dock { + Some(SavedDock::Left) => Place::Docked(Edge::Start), + Some(SavedDock::Right) => Place::Docked(Edge::End), + Some(SavedDock::Float) => Place::Floating, + None => default.place, + }; + PaneState { place, size: saved.width.or(default.size), float_rect: saved.float } +} + +/// The Styles pane's place and size, from `view.stylesPaneDock` and the UI preferences (#1756). +fn styles_state(app: &WordApp) -> PaneState { + let place = match app.session.view.styles_pane_dock { + StylesPaneDock::Right => Place::Docked(Edge::End), + StylesPaneDock::Left => Place::Docked(Edge::Start), + StylesPaneDock::Floating => Place::Floating, + }; + PaneState { place, size: app.ui.styles_pane_width.or(Pane::Styles.default_state().size), float_rect: app.ui.styles_pane_float } +} + +fn labels() -> PaneLabels { + PaneLabels { + menu: tl!("Pane Options").into(), + move_start: tl!("Dock Left").into(), + move_end: tl!("Dock Right").into(), + float: tl!("Float").into(), + close: tl!("Close").into(), + move_pane: tl!("Move Pane: {title}").into(), + resize: tl!("Resize Pane").into(), + ..PaneLabels::default() + } +} + +struct Content<'a> { + app: &'a mut WordApp, +} + +impl PaneContent for Content<'_> { + fn title(&self, pane: &Pane) -> String { + tl!(pane.title()).into() + } + + fn body(&mut self, ui: &mut Ui, pane: &Pane) { + let app = &mut *self.app; + match pane { + Pane::SmartArtText => crate::dialogs_smart_art::text_pane(app, ui), + Pane::Navigation => nav(app, ui), + Pane::Clipboard => clipboard(app, ui), + Pane::Styles => styles(app, ui), + Pane::StyleInspector => inspector(app, ui), + Pane::Accessibility => crate::a11y_pane::accessibility(app, ui), + Pane::AltText => crate::a11y_pane::alt_text(app, ui), + Pane::FormatShape => { + egui::ScrollArea::vertical().show(ui, |ui| crate::format_shape::pane(app, ui)); } + Pane::RestrictEditing => crate::dialogs_review::restrict_pane(app, ui), + Pane::Comments => comments(app, ui), + } + } + + fn default_state(&self, pane: &Pane) -> PaneState { + pane.default_state() + } +} + +/// The pane host's name. Its focus and drag state is per window ([`WordApp::window_id`]); where +/// each pane sits is the app's. +const HOST: &str = "task_panes"; + +/// Whether a dialog, file browser, colors dialog, content-control popup, Zotero alert, keytips +/// or egui popup is open: the keyboard belongs to it, not to the panes or the document. +pub(crate) fn overlay_open(app: &WordApp, ctx: &egui::Context) -> bool { + app.dialog.is_some() + || app.file_dialog_open() + || app.colors.is_some() + || app.controls.popup_open() + || app.zotero.alert_open() + || app.ui.keytips != crate::keytips::Phase::Off + || egui::Popup::is_any_open(ctx) +} + +/// F6 and Shift+F6 move the keyboard through the panes and back to the document. The key stays +/// with an open dialog, file browser, popup or keytips (Customize Keyboard records it), and an +/// F6 the user bound to a command runs that command instead. +fn route_f6(app: &WordApp, ctx: &egui::Context) { + if overlay_open(app, ctx) { + return; + } + let reg = &app.session.registry; + let bound = |m: egui::Modifiers| app.session.keymap.resolve(reg, &crate::keys::combo(m, "F6", true)).is_some(); + let mut cycle = None; + ctx.input_mut(|input| { + input.events.retain(|e| match e { + egui::Event::Key { key: egui::Key::F6, pressed: true, modifiers: m, .. } if !m.command && !m.ctrl && !m.mac_cmd && !bound(*m) => { + cycle = Some(m.shift); + false + } + _ => true, }); }); - ui.add_space(4.0); - close + if let Some(reverse) = cycle { + craft_ui::panes::focus_next(ctx, app.window_id(HOST), reverse); + } +} + +/// Draw the open panes around the document. Call before the canvas's `CentralPanel`. +pub fn show(app: &mut WordApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let open = open(app); + if !open.contains(&Pane::FormatShape) { + crate::format_shape::forget_drag(ui.ctx()); + } + route_f6(app, ui.ctx()); + let states: Vec<(Pane, PaneState)> = open.iter().map(|pane| (*pane, pane_state(app, *pane))).collect(); + let out = PaneHost::new(app.window_id(HOST)) + .policy(SidePolicy::Columns) + .f6(false) + .labels(labels()) + .frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.border))) + .body_margin(egui::Margin::same(10)) + .show(ui, &open, |pane| states.iter().find(|(p, _)| p == pane).map(|(_, state)| *state), &mut Content { app }); + for request in out.requests { + apply(app, request); + } + if out.to_document { + app.canvas.want_focus = true; + } +} + +/// Run a request from the pane host through the pane's commands and the UI preferences. +pub(crate) fn apply(app: &mut WordApp, request: PaneRequest) { + match request { + PaneRequest::Close(pane) => close(app, pane), + PaneRequest::Place(Pane::Styles, place) => { + let dock = match place { + Place::Docked(Edge::Start) => "left", + Place::Docked(Edge::End) => "right", + Place::Floating => "float", + Place::Docked(Edge::Bottom) => return, + }; + let _ = app.run("view.stylesPaneDock", json!({"dock": dock})); + } + PaneRequest::Resize(Pane::Styles, width) => app.ui.styles_pane_width = Some(width), + PaneRequest::FloatRect(Pane::Styles, rect) => app.ui.styles_pane_float = Some(rect), + PaneRequest::Place(pane, place) => { + let dock = match place { + Place::Docked(Edge::Start) => "left", + Place::Docked(Edge::End) => "right", + Place::Floating => "float", + Place::Docked(Edge::Bottom) => return, + }; + let _ = app.run("ui.paneDock", json!({"pane": pane.key(), "dock": dock})); + } + PaneRequest::Resize(pane, width) => app.ui.pane_places.entry(pane.key().into()).or_default().width = Some(width), + PaneRequest::FloatRect(pane, rect) => app.ui.pane_places.entry(pane.key().into()).or_default().float = Some(rect), + PaneRequest::Before(pane, before) => { + let _ = app.run("ui.paneDock", json!({"pane": pane.key(), "before": before.map(Pane::key)})); + } + PaneRequest::Focus(_) => {} + } +} + +/// `ui.paneDock {"pane": key, "dock"?: "left" | "right" | "float", "before"?: key | null}` (#1756): +/// docks or floats a task pane, as its menu's Dock Left, Dock Right and Float do; without `dock` +/// it reads the pane's place. `pane` is the pane's `panePlaces` key; Styles goes through +/// `view.stylesPaneDock`. `before` puts the pane directly before that pane in the order, outside +/// it on their edge, as a drop over a docked pane does; `null` puts it after every pane. +pub(crate) fn dock_command(app: &mut WordApp, p: &Value) -> Result { + let key = p.get("pane").and_then(Value::as_str).ok_or("`pane` must be a task pane's key, such as \"navigation\"")?; + let pane = Pane::from_key(key).ok_or_else(|| format!("unknown task pane `{key}`"))?; + let dock = match p.get("dock") { + None => None, + Some(v) => Some(match v.as_str() { + Some("left") => SavedDock::Left, + Some("right") => SavedDock::Right, + Some("float") => SavedDock::Float, + _ => return Err("`dock` must be left, right or float".into()), + }), + }; + let before = match p.get("before") { + None => None, + Some(Value::Null) => Some(None), + Some(v) => match v.as_str().and_then(Pane::from_key) { + Some(before) if before != pane => Some(Some(before)), + _ => return Err("`before` must be another task pane's key or null".into()), + }, + }; + if let Some(before) = before { + move_before(app, pane, before); + } + if pane == Pane::Styles { + let params = p.get("dock").map_or_else(|| json!({}), |d| json!({"dock": d})); + let result = app.run("view.stylesPaneDock", params)?; + return Ok(json!({"pane": key, "dock": result.get("dock")})); + } + if let Some(dock) = dock { + app.ui.pane_places.entry(key.into()).or_default().dock = Some(dock); + } + let name = match pane_state(app, pane).place { + Place::Docked(Edge::Start) => "left", + Place::Floating => "float", + Place::Docked(_) => "right", + }; + Ok(json!({"pane": key, "dock": name})) +} + +fn close(app: &mut WordApp, pane: Pane) { + match pane.close_command() { + Some(id) => { + let _ = app.run(id, json!({"value": false})); + } + None => app.restrict.open = false, + } } /// Draws the Navigation pane: search box and tabs, page list, find results and heading outline. fn nav(app: &mut WordApp, ui: &mut Ui) { - if header(ui, "Navigation") { - let _ = app.run("view.navigationPane", json!({"value": false})); - return; - } - let qid = egui::Id::unique("nav_query"); + let qid = app.window_id("nav_query"); let mut q = ui.data(|d| d.get_temp::(qid)).unwrap_or_default(); // Ctrl+F with text selected searches for it, as Word does (a selection within one line). let focus = std::mem::take(&mut app.nav_search_focus); @@ -150,7 +477,7 @@ fn nav(app: &mut WordApp, ui: &mut Ui) { } } ui.add_space(4.0); - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { for (k, l) in [("headings", "Headings"), ("pages", "Pages"), ("results", "Results")] { if ui.selectable_label(app.ui.nav_tab == k, tl!(l)).clicked() { app.ui.nav_tab = k.into(); @@ -312,12 +639,8 @@ fn nav_heading(ui: &mut Ui, level: u8, text: &str, selected: bool, collapsed: Op /// Items collected by Copy and Cut, newest first; clicking one pastes it at the caret. fn clipboard(app: &mut WordApp, ui: &mut Ui) { - if header(ui, "Clipboard") { - let _ = app.run("edit.clipboardPane", json!({"value": false})); - return; - } let items: Vec = app.session.clip_history.items().iter().map(|i| i.preview()).collect(); - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { if ui.add_enabled(!items.is_empty(), egui::Button::new(tl!("Paste All"))).clicked() { let _ = app.run("edit.pasteAllClipboard", json!({})); app.canvas.want_focus = true; @@ -358,10 +681,6 @@ fn clipboard(app: &mut WordApp, ui: &mut Ui) { } fn styles(app: &mut WordApp, ui: &mut Ui) { - if header(ui, "Styles") { - let _ = app.run("view.stylesPane", json!({"value": false})); - return; - } let st = app.session.run("format.state", &json!({})).unwrap_or_default(); let cur = st.get("style").and_then(Value::as_str).unwrap_or("Normal").to_string(); let current_name = app @@ -372,7 +691,8 @@ fn styles(app: &mut WordApp, ui: &mut Ui) { .map(crate::i18n::style_name) .unwrap_or_else(|| st.get("styleName").and_then(Value::as_str).unwrap_or("").to_string()); ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Current style: {style}"), &[("style", ¤t_name)])).small()); - ui.horizontal(|ui| { + // Wrapped: the host gives the pane a fixed width, and the row is wider than its default. + ui.horizontal_wrapped(|ui| { if ui.button(tl!("New Style…")).clicked() { app.dialog = crate::dialogs::Dialog::open("newStyle", app); } @@ -435,16 +755,14 @@ fn styles(app: &mut WordApp, ui: &mut Ui) { /// Draws the Comments pane: New Comment and scrollable comment and reply cards with edit, go-to, /// reply, resolve or reopen, and delete actions. fn comments(app: &mut WordApp, ui: &mut Ui) { - if header(ui, "Comments") { - let _ = app.run("view.commentsPane", json!({"value": false})); - return; - } if crate::widgets::icon_text_button(ui, "newComment", tl!("New Comment"), regular(12.5)).clicked() { let _ = app.run("review.newComment", json!({"text": ""})); } ui.separator(); let list = app.session.run("review.comments", &json!({})).unwrap_or_default(); let t = Tokens::get(ui.ctx()); + // Comment ids are the document's own; another window's document has the same ones. + let document = app.session.document_id(); egui::ScrollArea::vertical().show(ui, |ui| { for c in list.as_array().cloned().unwrap_or_default() { let id = c.get("id").and_then(Value::as_u64).unwrap_or(0); @@ -464,7 +782,7 @@ fn comments(app: &mut WordApp, ui: &mut Ui) { }); // Editable comment text (one undo step per edit, like the balloons). comment_editor(app, ui, id as u32, None, false); - let open_key = egui::Id::unique(("comment_reply_open", id)); + let open_key = egui::Id::unique(("comment_reply_open", document, id)); let mut replying = ui.data(|d| d.get_temp::(open_key)).unwrap_or(false); ui.horizontal(|ui| { if ui.small_button(tl!("Go to")).clicked() @@ -475,7 +793,7 @@ fn comments(app: &mut WordApp, ui: &mut Ui) { if ui.small_button(tl!("Reply")).clicked() { replying = true; ui.data_mut(|d| d.insert_temp(open_key, true)); - ui.data_mut(|d| d.insert_temp(egui::Id::unique(("comment_reply_focus", id)), true)); + ui.data_mut(|d| d.insert_temp(egui::Id::unique(("comment_reply_focus", document, id)), true)); } let resolved = c.get("resolved").and_then(Value::as_bool).unwrap_or(false); if ui.small_button(if resolved { tl!("Reopen") } else { tl!("Resolve") }).clicked() { @@ -486,7 +804,7 @@ fn comments(app: &mut WordApp, ui: &mut Ui) { } }); if replying { - let focus = ui.data_mut(|d| d.remove_temp::(egui::Id::unique(("comment_reply_focus", id)))).unwrap_or(false); + let focus = ui.data_mut(|d| d.remove_temp::(egui::Id::unique(("comment_reply_focus", document, id)))).unwrap_or(false); if reply_editor(app, ui, id as u32, None, focus) { ui.data_mut(|d| d.remove::(open_key)); } @@ -518,6 +836,11 @@ impl CommentDrafts { } self } + + /// Nothing typed and not yet written. + pub(crate) fn is_empty(&self) -> bool { + self.edits.is_empty() && self.replies.is_empty() + } } /// The pending draft of comment `id`'s text in the open document, if any. @@ -671,10 +994,6 @@ pub(crate) fn flush_comment_drafts(app: &mut WordApp) -> Result<(), String> { /// Style Inspector: the paragraph and character levels at the caret, each with its style and the /// direct formatting on top, and a button to clear each level. fn inspector(app: &mut WordApp, ui: &mut Ui) { - if header(ui, "Style Inspector") { - let _ = app.run("styles.inspector", json!({"value": false})); - return; - } // Read-only, so call it directly: running the command every frame would pay for the command // machinery and consume per-command state (a pending `join_next_undo`) just to draw the pane. let r = wordcraft_engine::cmd::inspector::inspect(&app.session); @@ -908,6 +1227,10 @@ fn camel_words(s: &str) -> String { #[cfg(test)] mod tests { + use super::{Content, Pane}; + use craft_ui::panes::PaneContent; + use egui::vec2; + /// #1902: Clipboard Delete returns focus to the document like paste does. #[test] #[cfg(not(target_arch = "wasm32"))] @@ -939,6 +1262,359 @@ mod tests { assert!(h.state().session.clip_history.items().is_empty(), "the item was deleted"); } + /// F6 past the last pane gives the document the keyboard, and typing reaches it (#1756). + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn f6_past_the_last_pane_types_into_the_document() { + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("")), crate::Services::default()); + app.run("view.navigationPane", serde_json::json!({"value": true})).unwrap(); + app.run("edit.clipboardPane", serde_json::json!({"value": true})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut crate::WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + assert!(h.state().canvas.focused); + let f6 = egui::Event::Key { key: egui::Key::F6, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + for pane in ["Navigation", "Clipboard"] { + h.input_mut().events.push(f6.clone()); + h.run_steps(2); + assert!(!h.state().canvas.focused, "F6 moved the keyboard to the {pane} pane"); + } + h.input_mut().events.push(f6); + h.run_steps(2); + assert!(h.state().canvas.focused, "F6 past the last pane returns to the document"); + h.input_mut().events.push(egui::Event::Text("typed".into())); + h.run_steps(2); + assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), "typed"); + } + + fn pane_harness(app: crate::WordApp) -> egui_kittest::Harness<'static, crate::WordApp> { + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut crate::WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h + } + + fn press(h: &mut egui_kittest::Harness<'static, crate::WordApp>, key: egui::Key, modifiers: egui::Modifiers) { + h.input_mut().events.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }); + h.run_steps(2); + } + + /// F6 and Shift+F6 with a dialog open leave the keyboard in the dialog: no pane title bar and + /// no document behind it takes it. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn f6_in_an_open_dialog_keeps_focus_there() { + for panes in [true, false] { + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("")), crate::Services::default()); + app.run("view.navigationPane", serde_json::json!({"value": panes})).unwrap(); + app.run("ui.dialog", serde_json::json!({"name": "replace"})).unwrap(); + let mut h = pane_harness(app); + let focused = h.ctx.memory(|m| m.focused()); + for shift in [false, true] { + press(&mut h, egui::Key::F6, if shift { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE }); + assert_eq!(h.ctx.memory(|m| m.focused()), focused, "panes open: {panes}, shift: {shift}"); + assert!(!h.state().canvas.focused && h.state().dialog.is_some(), "panes open: {panes}, shift: {shift}"); + } + } + } + + /// Ctrl and F-key shortcuts run while F6 has put the keyboard on a pane's title bar; a pane's + /// text field keeps Ctrl+H for itself. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn shortcuts_run_while_a_pane_title_bar_has_focus() { + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("")), crate::Services::default()); + app.run("view.navigationPane", serde_json::json!({"value": true})).unwrap(); + let mut h = pane_harness(app); + press(&mut h, egui::Key::F6, egui::Modifiers::NONE); + assert!(!h.state().canvas.focused && h.ctx.memory(|m| m.focused()).is_some(), "the title bar has the keyboard"); + press(&mut h, egui::Key::H, egui::Modifiers::COMMAND); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("replace"), "Ctrl+H opened Replace"); + press(&mut h, egui::Key::Escape, egui::Modifiers::NONE); + assert!(h.state().dialog.is_none()); + + h.state_mut().nav_search_focus = true; + h.run_steps(2); + assert!(h.ctx.text_edit_focused()); + press(&mut h, egui::Key::H, egui::Modifiers::COMMAND); + assert!(h.state().dialog.is_none(), "the search field keeps Ctrl+H"); + } + + /// A dialog button with the keyboard keeps Ctrl+Z away from the document behind the dialog. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn shortcuts_stay_out_of_the_document_while_a_dialog_button_has_focus() { + use egui_kittest::kittest::Queryable; + + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("")), crate::Services::default()); + app.run("text.insert", serde_json::json!({"text": "kept"})).unwrap(); + app.run("ui.dialog", serde_json::json!({"name": "wordCount"})).unwrap(); + let mut h = pane_harness(app); + // Two buttons are labelled Close (the dialog's title bar and its own row); either will do. + if let Some(close) = h.query_all_by_label("Close").next() { + close.focus(); + } + h.run_steps(2); + assert!(!h.state().canvas.focused, "the document gave up the keyboard"); + assert!(!h.ctx.text_edit_focused() && h.ctx.memory(|m| m.focused()).is_some(), "Close has the keyboard"); + press(&mut h, egui::Key::Z, egui::Modifiers::COMMAND); + assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), "kept", "Ctrl+Z left the document alone"); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("wordCount")); + } + + /// Escape during a pane's title-bar drag cancels the drag and leaves full screen on. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn escape_during_a_pane_drag_keeps_full_screen() { + use egui_kittest::kittest::Queryable; + + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("")), crate::Services::default()); + app.run("view.navigationPane", serde_json::json!({"value": true})).unwrap(); + app.run("view.fullScreen", serde_json::json!({"value": true})).unwrap(); + let mut h = pane_harness(app); + let title = h.get_by_label_contains("Move Pane: ").rect().center(); + let button = + |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + h.input_mut().events.extend([egui::Event::PointerMoved(title), button(title, true)]); + h.run_steps(2); + let away = title + egui::vec2(300.0, 200.0); + h.input_mut().events.push(egui::Event::PointerMoved(away)); + h.run_steps(2); + assert!(h.ctx.dragged_id().is_some(), "the title bar is dragged"); + press(&mut h, egui::Key::Escape, egui::Modifiers::NONE); + assert!(h.state().session.view.full_screen); + h.input_mut().events.push(button(away, false)); + h.run_steps(2); + assert!(h.state().session.view.full_screen); + let docked = craft_ui::panes::Place::Docked(craft_ui::panes::Edge::Start); + assert_eq!(super::pane_state(h.state(), Pane::Navigation).place, docked, "the drag was cancelled"); + } + + /// With Navigation docked left, Comments dropped over Navigation's outer third docks left of + /// it, where the preview showed, and the order survives a restart; over Navigation's inner + /// third it goes back inside. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn comments_dropped_over_navigations_outer_third_docks_left_of_it() { + use egui_kittest::kittest::Queryable; + + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("")), crate::Services::default()); + app.run("view.navigationPane", serde_json::json!({"value": true})).unwrap(); + app.run("view.commentsPane", serde_json::json!({"value": true})).unwrap(); + let mut h = pane_harness(app); + // A docked pane's title bar starts at its panel's left edge. + let panel = |h: &egui_kittest::Harness<'static, crate::WordApp>, pane: Pane| h.get_by_label(&format!("Move Pane: {}", pane.title())).rect(); + let button = + |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + let drag = |h: &mut egui_kittest::Harness<'static, crate::WordApp>, to: egui::Pos2| { + let from = h.get_by_label("Move Pane: Comments").rect().center(); + h.input_mut().events.extend([egui::Event::PointerMoved(from), button(from, true)]); + h.run_steps(1); + h.input_mut().events.push(egui::Event::PointerMoved(from + egui::vec2(-8.0, 6.0))); + h.run_steps(1); + h.input_mut().events.push(egui::Event::PointerMoved(to)); + h.run_steps(1); + h.input_mut().events.push(button(to, false)); + h.run_steps(3); + }; + let nav = panel(&h, Pane::Navigation); + drag(&mut h, egui::pos2(nav.left() + 60.0, nav.center().y)); + let (comments, nav_after) = (panel(&h, Pane::Comments), panel(&h, Pane::Navigation)); + assert_eq!(comments.left(), nav.left(), "Comments is outermost"); + assert!(nav_after.left() > comments.right(), "Navigation sits inside it"); + assert_eq!(super::open(h.state()), vec![Pane::Comments, Pane::Navigation]); + + let saved = serde_json::to_vec(&h.state().prefs()).unwrap(); + let mut next = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + next.apply_prefs(serde_json::from_slice(&saved).unwrap()); + next.session.view.comments_pane = true; + assert_eq!(super::open(&next), vec![Pane::Comments, Pane::Navigation], "the order is kept"); + + let nav = panel(&h, Pane::Navigation); + let width = Pane::Navigation.default_state().size.unwrap(); + drag(&mut h, egui::pos2(nav.left() + width * 0.85, nav.center().y)); + assert_eq!(super::open(h.state()), vec![Pane::Navigation, Pane::Comments]); + assert!(panel(&h, Pane::Comments).left() > panel(&h, Pane::Navigation).right(), "back inside Navigation"); + + let mut junk = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + junk.apply_prefs(serde_json::from_str(r#"{"paneOrder": ["comments", 3, "nope", "comments", "navigation"], "navPane": true}"#).unwrap()); + assert_eq!(junk.ui.pane_order, vec!["comments", "navigation"], "unknown and repeated keys are dropped"); + for bad in [serde_json::json!({"pane": "comments", "before": "comments"}), serde_json::json!({"pane": "comments", "before": 7})] { + assert!(junk.run("ui.paneDock", bad.clone()).is_err(), "{bad}"); + } + junk.run("ui.paneDock", serde_json::json!({"pane": "navigation", "before": null})).unwrap(); + assert_eq!(junk.ui.pane_order.last().map(String::as_str), Some("navigation")); + } + + /// An F6 the user bound runs its command; plain F6 still moves to the panes when only + /// Shift+F6 is bound. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn a_user_bound_f6_runs_its_command() { + for key in ["F6", "Shift+F6"] { + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("")), crate::Services::default()); + app.run("view.navigationPane", serde_json::json!({"value": true})).unwrap(); + app.session.keymap = wordcraft_engine::keymap::KeyMap::from_value(&serde_json::json!({"assigned": {key: "view.focus"}})); + let mut h = pane_harness(app); + assert!(h.state().canvas.focused); + let m = if key == "F6" { egui::Modifiers::NONE } else { egui::Modifiers::SHIFT }; + press(&mut h, egui::Key::F6, m); + assert!(h.state().session.view.focus_mode, "{key} ran its command"); + press(&mut h, egui::Key::F6, m); + assert!(!h.state().session.view.focus_mode, "{key} ran its command again"); + if key == "Shift+F6" { + h.state_mut().canvas.want_focus = true; + h.run_steps(2); + press(&mut h, egui::Key::F6, egui::Modifiers::NONE); + assert!(!h.state().canvas.focused, "plain F6 moved the keyboard to the pane"); + } + } + } + + /// Every pane's body fits a pane at the 200 pt minimum width (the host clips what doesn't): + /// button rows wrap and fields narrow. Restrict Editing shows its style list and password + /// fields. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn pane_bodies_fit_the_narrowest_pane() { + // 200 pt less the 10 pt body margins and the frame's 1 pt strokes. + const BODY: f32 = 178.0; + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Hello")), crate::Services::default()); + app.run("select.all", serde_json::json!({})).unwrap(); + app.run("edit.copy", serde_json::json!({})).unwrap(); + app.restrict = crate::dialogs_review::RestrictPane { + open: true, + formatting: true, + show_styles: true, + editing: true, + asking: true, + ..Default::default() + }; + let panes = [ + Pane::Navigation, + Pane::Clipboard, + Pane::Styles, + Pane::StyleInspector, + Pane::Accessibility, + Pane::AltText, + Pane::FormatShape, + Pane::RestrictEditing, + Pane::Comments, + ]; + let widths = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let seen = widths.clone(); + let mut fonts = false; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 1200.0)).build_ui_state( + move |ui, app: &mut crate::WordApp| { + // Fonts take effect the frame after they are installed. + if !fonts { + crate::theme::install_fonts(ui.ctx()); + fonts = true; + return; + } + let mut row = Vec::new(); + for pane in panes { + let width = ui + .allocate_ui_with_layout(vec2(BODY, 1200.0), egui::Layout::top_down(egui::Align::Min), |ui| { + ui.set_max_width(BODY); + Content { app: &mut *app }.body(ui, &pane); + ui.min_rect().width() + }) + .inner; + row.push((pane, width)); + } + *seen.borrow_mut() = row; + }, + app, + ); + h.run_steps(3); + let widths = widths.borrow(); + assert_eq!(widths.len(), panes.len()); + let wide: Vec<_> = widths.iter().filter(|(_, width)| *width > BODY + 0.5).collect(); + assert!(wide.is_empty(), "wider than the pane: {wide:?}"); + } + + /// A pane other than Styles floated from its menu comes back floating, at the same rect, after + /// a restart; junk in `panePlaces` reads as the defaults (#1756). + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn a_floated_pane_floats_again_after_a_restart() { + use craft_ui::panes::Place; + use egui_kittest::kittest::Queryable; + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("")), crate::Services::default()); + app.run("view.navigationPane", serde_json::json!({"value": true})).unwrap(); + let mut h = pane_harness(app); + let bar = h.get_by_label_contains("Move Pane: ").rect(); + let on_row = |r: egui::Rect| r.center().y > bar.top() && r.center().y < bar.bottom(); + h.get_all_by_label("Pane Options").find(|n| on_row(n.rect())).expect("the menu button beside the title bar").click(); + h.run_steps(2); + h.get_by_label("Float").click(); + h.run_steps(3); + assert_eq!(super::pane_state(h.state(), Pane::Navigation).place, Place::Floating); + let rect = h.state().ui.pane_places.get("navigation").and_then(|p| p.float).expect("the float rect is kept"); + + let saved = serde_json::to_vec(&h.state().prefs()).unwrap(); + let mut next = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + next.apply_prefs(serde_json::from_slice(&saved).unwrap()); + let state = super::pane_state(&next, Pane::Navigation); + assert_eq!((state.place, state.float_rect), (Place::Floating, Some(rect))); + assert_eq!(super::pane_state(&next, Pane::Clipboard), Pane::Clipboard.default_state(), "other panes keep their defaults"); + + let mut junk = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + junk.apply_prefs( + serde_json::from_str( + r#"{"panePlaces": {"navigation": {"dock": "top", "float": [1, "x", 3, 4]}, "clipboard": 7, "nope": {}}, "navPane": true}"#, + ) + .unwrap(), + ); + assert_eq!(super::pane_state(&junk, Pane::Navigation), Pane::Navigation.default_state(), "junk reads as the default"); + assert!(junk.session.view.nav_pane, "the rest of the file still loads"); + let mut odd = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + odd.apply_prefs(serde_json::from_str(r#"{"panePlaces": [1, 2], "navPane": true}"#).unwrap()); + assert!(odd.ui.pane_places.is_empty() && odd.session.view.nav_pane); + } + + /// `ui.paneDock` docks or floats any pane and reads its place, as the pane's menu does + /// (#1756); Styles goes through `view.stylesPaneDock`; junk is refused and changes nothing. + #[test] + fn pane_dock_command_docks_floats_and_reads_each_pane() { + use craft_ui::panes::{Edge, Place}; + use serde_json::json; + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("")), crate::Services::default()); + assert_eq!(app.run("ui.paneDock", json!({"pane": "navigation"})).unwrap(), json!({"pane": "navigation", "dock": "left"})); + assert_eq!(app.run("ui.paneDock", json!({"pane": "comments"})).unwrap(), json!({"pane": "comments", "dock": "right"})); + assert_eq!(app.run("ui.paneDock", json!({"pane": "navigation", "dock": "float"})).unwrap()["dock"], "float"); + assert_eq!(super::pane_state(&app, Pane::Navigation).place, Place::Floating); + app.run("ui.paneDock", json!({"pane": "comments", "dock": "left"})).unwrap(); + assert_eq!(super::pane_state(&app, Pane::Comments).place, Place::Docked(Edge::Start)); + assert_eq!(app.run("ui.paneDock", json!({"pane": "styles", "dock": "float"})).unwrap(), json!({"pane": "styles", "dock": "float"})); + assert_eq!(app.session.view.styles_pane_dock, wordcraft_engine::StylesPaneDock::Floating); + assert!(!app.ui.pane_places.contains_key("styles"), "Styles keeps its place in the view state"); + for junk in [ + json!({}), + json!({"pane": "nope", "dock": "left"}), + json!({"pane": 3}), + json!({"pane": "navigation", "dock": "top"}), + json!({"pane": "navigation", "dock": 1}), + json!({"pane": "navigation", "dock": null}), + ] { + assert!(app.run("ui.paneDock", junk.clone()).is_err(), "{junk}"); + } + assert_eq!(super::pane_state(&app, Pane::Navigation).place, Place::Floating, "junk changed nothing"); + } + /// #3032: Delete is greyed out for built-in styles in the Styles pane (the engine refuses /// them — `crates/engine/src/cmd/para.rs`), while custom styles keep it enabled. #[test] diff --git a/crates/ui-egui/src/previews.rs b/crates/ui-egui/src/previews.rs index b8e73a012f..0331bd71ef 100644 --- a/crates/ui-egui/src/previews.rs +++ b/crates/ui-egui/src/previews.rs @@ -296,6 +296,11 @@ impl Previews { Some(h) } + /// Another document is shown: its revisions count apart from the last one's. + pub(crate) fn document_changed(&mut self) { + self.styles_hash = (u64::MAX, 0); + } + /// Hash of the style sheet (recomputed only when the document revision changes). fn styles_key(&mut self, app_doc: &Document, rev: u64) -> u64 { if self.styles_hash.0 != rev { @@ -517,7 +522,8 @@ pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) { let rev = app.session.rev(); let skey = app.previews.styles_key(&app.session.doc, rev); let ppp = ui.ctx().pixels_per_point(); - let start = ui.data(|d| d.get_temp::(egui::Id::unique("gallery_start"))).unwrap_or(0).min(styles.len().saturating_sub(1)); + let start_id = app.window_id("gallery_start"); + let start = ui.data(|d| d.get_temp::(start_id)).unwrap_or(0).min(styles.len().saturating_sub(1)); // A compact ribbon group (#662, #1285) trims the gallery to fewer tiles before it folds. let visible = if crate::ribbon_fit::compact(ui.ctx()) { STYLE_TILES_COMPACT } else { STYLE_TILES }; crate::ribbon_fit::add_saving(ui.ctx(), (STYLE_TILES - STYLE_TILES_COMPACT) as f32 * 76.0); @@ -590,7 +596,7 @@ pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) { let _ = app.run("view.stylesPane", json!({"value": true})); } else { let n = if *delta < 0 { start.saturating_sub(visible) } else { (start + visible).min(styles.len().saturating_sub(1)) }; - ui.data_mut(|d| d.insert_temp(egui::Id::unique("gallery_start"), n)); + ui.data_mut(|d| d.insert_temp(start_id, n)); } } } diff --git a/crates/ui-egui/src/read_aloud.rs b/crates/ui-egui/src/read_aloud.rs index 8a8fafa95b..289c44a394 100644 --- a/crates/ui-egui/src/read_aloud.rs +++ b/crates/ui-egui/src/read_aloud.rs @@ -29,7 +29,8 @@ pub fn poll(app: &mut WordApp, ctx: &egui::Context) { app.read_aloud_error = Some(e.clone()); app.status(e); } - let current = app.session.read_aloud.current().map(|u| u.start); + // The caret follows the reading only in the document being read. + let current = if reads_shown_document(app) { app.session.read_aloud.current().map(|u| u.start) } else { None }; if current != app.read_aloud_at { if let Some(start) = ¤t { app.session.sel = wordcraft_engine::Selection::caret(app.session.doc.clamp(start)); @@ -42,18 +43,31 @@ pub fn poll(app: &mut WordApp, ctx: &egui::Context) { /// The page rectangles (in page units) of the sentence being read, to highlight. pub fn highlight(app: &mut WordApp) -> Option<(wordcraft_engine::doc::Pos, wordcraft_engine::doc::Pos)> { - if !app.session.read_aloud.open { + if !app.session.read_aloud.open || !reads_shown_document(app) { return None; } let u = app.session.read_aloud.current()?; Some((app.session.doc.clamp(&u.start), app.session.doc.clamp(&u.end))) } +/// Whether the reading is of the document the window shows: its sentence positions are in it. +fn reads_shown_document(app: &WordApp) -> bool { + app.session.read_aloud.document == Some(app.session.document_id()) +} + /// The player window. pub fn show(app: &mut WordApp, ctx: &egui::Context) { if !app.session.read_aloud.open { return; } + // With a window per document, the player is in the window of the document being read. + if app.windows.native() + && let Some(d) = app.session.read_aloud.document + && d != app.session.document_id() + && app.session.is_open(d) + { + return; + } let t = Tokens::get(ctx); let state = app.session.read_aloud.state(); let (index, count) = app.session.read_aloud.progress(); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index d78f8f88e4..3c795b59eb 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2354,7 +2354,7 @@ fn view(app: &mut WordApp, ui: &mut Ui) { big(ui, app, "newWindow", "New\nWindow", "view.newWindow", json!({}), false); big(ui, app, "arrangeAll", "Arrange\nAll", "view.arrangeAll", json!({}), false); big(ui, app, "split", "Split", "view.split", json!({}), false); - // Across WordCraft windows (#322; `windows`): each is its own process and document. + // Across the app's windows (#322; `windows`). let w = app.session.windows.clone(); stack(ui, |ui| { if w.side_by_side.is_none() && w.others.len() > 1 { @@ -2366,9 +2366,16 @@ fn view(app: &mut WordApp, ui: &mut Ui) { } small(ui, app, "syncScroll", Some("Synchronous Scrolling"), "Synchronous Scrolling", "view.syncScroll", json!({}), w.sync_scroll); }); - menu_button(ui, app, "switchWindows", Some("Switch\nWindows"), "Switch Windows", true, "view.switchWindows", |ui, app| { - window_list(ui, app, &w.others, "view.switchWindows") - }); + menu_button( + ui, + app, + "switchWindows", + Some("Switch\nWindows"), + "Switch Windows", + true, + "view.switchWindows", + crate::documents::switch_windows_menu, + ); // The Full Screen button carries its name for assistive technology (and for the // regression test that clicks it): big buttons are otherwise drawn unlabeled. Its // enabled state is the command's own, as every other ribbon control reports: the diff --git a/crates/ui-egui/src/window_geometry.rs b/crates/ui-egui/src/window_geometry.rs index 1b456beb09..b58129b3ed 100644 --- a/crates/ui-egui/src/window_geometry.rs +++ b/crates/ui-egui/src/window_geometry.rs @@ -28,11 +28,15 @@ pub struct RawWindowState { impl RawWindowState { /// Copy the window state out of this frame's raw input. pub fn from_raw(raw: &egui::RawInput) -> Self { - let viewport = raw.viewports.get(&raw.viewport_id); + Self::from_viewport(raw.viewports.get(&raw.viewport_id), raw.screen_rect) + } + + /// The state of one viewport, from what egui knows about it and its content rectangle. + pub fn from_viewport(viewport: Option<&egui::ViewportInfo>, screen_rect: Option) -> Self { Self { inner_rect: viewport.and_then(|v| v.inner_rect).filter(|r| r.is_finite()), outer_rect: viewport.and_then(|v| v.outer_rect).filter(|r| r.is_finite()), - screen_rect: raw.screen_rect.filter(|r| r.is_finite()), + screen_rect: screen_rect.filter(|r| r.is_finite()), monitor_size: viewport.and_then(|v| v.monitor_size), maximized: viewport.and_then(|v| v.maximized), minimized: viewport.and_then(|v| v.minimized), diff --git a/crates/ui-egui/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs new file mode 100644 index 0000000000..9d938ee243 --- /dev/null +++ b/crates/ui-egui/src/window_ui.rs @@ -0,0 +1,401 @@ +//! What one window shows and is doing, apart from the rest of the app. [`WordApp`] holds the +//! state of the window being drawn in its own fields, so drawing code reads `app.canvas`, +//! `app.dialog`, `app.ui.tab` whatever window it is; every other window keeps its state in a +//! [`WindowUi`]. [`WordApp::swap_window`] exchanges the two by moving owned fields, so it is +//! cheap enough to run for every window on every frame. +//! +//! Every field of [`WordApp`] and [`UiState`] is one of: +//! - per window: listed in [`WindowUi`] and swapped; +//! - per document: kept for the documents not shown in [`WordApp`]'s `parked` map +//! ([`WordApp::sync_document`]) or in the engine's session; +//! - app-wide: the session, preferences, fonts, theme, the control channel, timers and workers. +//! +//! `swap_window` names every field, so a new field doesn't compile until it is classified. + +use crate::{AwaitingSave, UiState, WordApp, canvas, colors, content_controls, dialogs, dialogs_review, file_dialogs, keytips, previews, print_page}; + +/// A window's state while another window is drawn. Fields mirror their namesakes in [`WordApp`] +/// and, for the ribbon tab, Backstage, printing and keytips, in [`UiState`]. +pub(crate) struct WindowUi { + pub(crate) canvas: canvas::CanvasState, + pub(crate) controls: content_controls::ControlUi, + pub(crate) dialog: Option, + pub(crate) colors: Option, + pub(crate) restrict: dialogs_review::RestrictPane, + pub(crate) status_msg: Option<(String, f64)>, + pub(crate) previews: previews::Previews, + pub(crate) change_picture_target: Option, + pub(crate) recipient_list_pending: bool, + pub(crate) dialog_pick: Option, + pub(crate) focus_ribbon: bool, + pub(crate) control_click: bool, + pub(crate) keytip_rects: Vec<(egui::Rect, String)>, + pub(crate) nav_search_focus: bool, + pub(crate) comment_fonts_for: Option<(u64, u64)>, + pub(crate) find_fonts_for: Option<(String, usize, u64)>, + pub(crate) sent_title: String, + pub(crate) sent_full_screen: bool, + pub(crate) seen_full_screen: Option, + pub(crate) escape_free: bool, + pub(crate) word_count: (u64, usize), + pub(crate) last_autosave: f64, + pub(crate) equation_prev_tab: Option, + pub(crate) smart_art_item: Option, + pub(crate) smart_art_row_focused: bool, + pub(crate) smart_art_typing: Option, + pub(crate) outline_prev_tab: Option, + pub(crate) read_aloud_at: Option, + pub(crate) awaiting_save: Option, + pub(crate) file_dialog: Option, + #[cfg(not(target_arch = "wasm32"))] + pub(crate) file_browser: crate::file_browser::State, + pub(crate) info_editing: Option<(&'static str, u64)>, + pub(crate) shown_document: u64, + // From UiState. + pub(crate) tab: String, + pub(crate) backstage: bool, + pub(crate) backstage_page: String, + pub(crate) print: print_page::PrintSettings, + pub(crate) nav_collapsed: std::collections::HashSet, + pub(crate) keytip_prefix: String, + pub(crate) keytips: keytips::Phase, + pub(crate) alt_pressed: bool, + pub(crate) alt_chord_used: bool, + pub(crate) alt_key_down: bool, + pub(crate) alt_modifier_seen: bool, +} + +/// A word count that matches no document revision, so the next frame counts. +pub(crate) const NOT_COUNTED: (u64, usize) = (u64::MAX, 0); + +impl WindowUi { + /// A new window showing open document `document`: nothing open over the page, Home tab. + pub(crate) fn new(document: u64) -> WindowUi { + let UiState { + tab, + backstage, + backstage_page, + print, + nav_collapsed, + keytip_prefix, + keytips, + alt_pressed, + alt_chord_used, + alt_key_down, + alt_modifier_seen, + .. + } = UiState::default(); + WindowUi { + canvas: Default::default(), + controls: Default::default(), + dialog: None, + colors: None, + restrict: Default::default(), + status_msg: None, + previews: Default::default(), + change_picture_target: None, + recipient_list_pending: false, + dialog_pick: None, + focus_ribbon: false, + control_click: false, + keytip_rects: Vec::new(), + nav_search_focus: false, + comment_fonts_for: None, + find_fonts_for: None, + sent_title: String::new(), + sent_full_screen: false, + seen_full_screen: None, + escape_free: false, + word_count: NOT_COUNTED, + last_autosave: 0.0, + equation_prev_tab: None, + smart_art_item: None, + smart_art_row_focused: false, + smart_art_typing: None, + outline_prev_tab: None, + read_aloud_at: None, + awaiting_save: None, + file_dialog: None, + #[cfg(not(target_arch = "wasm32"))] + file_browser: Default::default(), + info_editing: None, + shown_document: document, + tab, + backstage, + backstage_page, + print, + nav_collapsed, + keytip_prefix, + keytips, + alt_pressed, + alt_chord_used, + alt_key_down, + alt_modifier_seen, + } + } +} + +/// Swap each named field of the drawn window (bound by the caller's destructuring) with its +/// namesake in the [`WindowUi`]. The `WindowUi` pattern is exhaustive, so a field added there +/// and left out here doesn't compile. +macro_rules! swap_fields { + ($w:ident: $($(#[$attr:meta])* $field:ident),* $(,)?) => { + let WindowUi { $($(#[$attr])* $field: _),* } = &*$w; + $($(#[$attr])* std::mem::swap($field, &mut $w.$field);)* + }; +} + +impl WordApp { + /// Exchange the drawn window's state with `w`'s: afterwards the app draws the window `w` + /// held, and `w` holds the one that was drawn. App-wide and per-document state stays. The + /// session's active document is not changed: the caller activates `w`'s document + /// (`shown_document`) with it, or [`WordApp::sync_document`] treats the change as a switch + /// within the window. + pub(crate) fn swap_window(&mut self, w: &mut WindowUi) { + let WordApp { + // App-wide. + session: _, + services: _, + printing: _, + print_feedback_seen: _, + font_generation: _, + integrated_titlebar: _, + mac_titlebar: _, + control_rx: _, + updates_rt: _, + // The control channel's screenshots and input go to the active window, whichever it is. + shots: _, + capture_epoch: _, + surface_geometry: _, + capture_geometry: _, + synthetic: _, + synthetic_pending: _, + synthetic_injected: _, + // The current command's origin, set and cleared around it. + ime_origin: _, + styled: _, + fonts_hans: _, + fonts_korean: _, + fonts_system_cjk: _, + want_system_cjk: _, + recent_fonts_for: _, + // File › Recover lists the copies of every window's documents. + recover_fonts_for: _, + fonts_frames: _, + applied_theme: _, + native_scale: _, + legacy_interface_size: _, + desktop_theme: _, + desktop_dark: _, + applied_editing: _, + system_animations: _, + applied_animations: _, + frame_ms: _, + quit_requested: _, + autosave: _, + zotero: _, + ctx: _, + read_aloud_error: _, + saves_in_flight: _, + recovery_seen: _, + no_document: _, + windows: _, + print_document: _, + // Per document: the shown document's, swapped with `parked` on a document switch. + autosave_path: _, + comment_drafts: _, + parked: _, + // Per window. + ui, + canvas, + controls, + dialog, + colors, + restrict, + status_msg, + previews, + change_picture_target, + recipient_list_pending, + dialog_pick, + focus_ribbon, + control_click, + keytip_rects, + nav_search_focus, + comment_fonts_for, + find_fonts_for, + sent_title, + sent_full_screen, + seen_full_screen, + escape_free, + word_count, + last_autosave, + equation_prev_tab, + smart_art_item, + smart_art_row_focused, + smart_art_typing, + outline_prev_tab, + read_aloud_at, + awaiting_save, + file_dialog, + #[cfg(not(target_arch = "wasm32"))] + file_browser, + info_editing, + shown_document, + } = self; + let UiState { + // App-wide: preferences, recent lists, and the settings the session owns while + // running. + ribbon_collapsed: _, + quick_access_toolbar: _, + recent: _, + recent_symbols: _, + recent_colors: _, + recent_fonts: _, + theme: _, + interface_size: _, + legacy_interface_size: _, + animations: _, + legacy_dark: _, + nav_tab: _, + nav_pane: _, + show_discord: _, + author: _, + window: _, + language: _, + editing: _, + read_aloud_rate: _, + read_aloud_skip_citations: _, + keytips_enabled: _, + dark_page: _, + check_spelling: _, + caret_animation: _, + keyboard: _, + ribbon: _, + caption_labels: _, + last_caption_label: _, + ink_pens: _, + initials: _, + options_pane: _, + recent_count: _, + mini_toolbar: _, + alignment_guides: _, + start_screen: _, + print_backgrounds: _, + update_fields_before_print: _, + dictionary: _, + // Where each task pane sits, and their order. + styles_pane_dock: _, + styles_pane_float: _, + styles_pane_width: _, + pane_places: _, + pane_order: _, + // Per window. + tab, + backstage, + backstage_page, + print, + nav_collapsed, + keytip_prefix, + keytips, + alt_pressed, + alt_chord_used, + alt_key_down, + alt_modifier_seen, + } = ui; + swap_fields!(w: + canvas, + controls, + dialog, + colors, + restrict, + status_msg, + previews, + change_picture_target, + recipient_list_pending, + dialog_pick, + focus_ribbon, + control_click, + keytip_rects, + nav_search_focus, + comment_fonts_for, + find_fonts_for, + sent_title, + sent_full_screen, + seen_full_screen, + escape_free, + word_count, + last_autosave, + equation_prev_tab, + smart_art_item, + smart_art_row_focused, + smart_art_typing, + outline_prev_tab, + read_aloud_at, + awaiting_save, + file_dialog, + #[cfg(not(target_arch = "wasm32"))] + file_browser, + info_editing, + shown_document, + tab, + backstage, + backstage_page, + print, + nav_collapsed, + keytip_prefix, + keytips, + alt_pressed, + alt_chord_used, + alt_key_down, + alt_modifier_seen, + ); + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use wordcraft_engine::Session; + + use super::WindowUi; + use crate::{Services, WordApp, dialogs::Dialog}; + + /// Swapping in a new window shows its fresh state; swapping back restores the first window's + /// dialog, status message, composition, scroll request and tab. The session and the + /// preferences stay put both ways. + #[test] + fn swap_window_round_trip() { + let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); + a.run("text.insert", json!({"text": "Kept"})).unwrap(); + let doc = a.session.document_id(); + let rev = a.session.rev(); + a.ui.recent_count = 7; + a.ui.tab = "Insert".into(); + a.dialog = + Some(Dialog::SaveChanges { name: "Report".into(), then: "file.close".into(), params: json!({}), document: doc, message: String::new() }); + a.status_msg = Some(("Saved".into(), 1.0)); + a.canvas.ime_preedit = "ni".into(); + a.canvas.ime_preedit_range = Some(0..2); + a.canvas.scroll_to_caret = false; + a.canvas.nav_to_caret = true; + + let mut w = WindowUi::new(doc); + a.swap_window(&mut w); + assert!(a.dialog.is_none() && a.status_msg.is_none()); + assert!(a.canvas.ime_preedit.is_empty() && a.canvas.ime_preedit_range.is_none()); + assert!(a.canvas.scroll_to_caret && !a.canvas.nav_to_caret, "a new window goes to the caret"); + assert_eq!(a.ui.tab, crate::DEFAULT_TAB); + assert_eq!(w.tab, "Insert"); + assert_eq!((a.session.document_id(), a.session.rev(), a.ui.recent_count), (doc, rev, 7), "app-wide state stays"); + + a.swap_window(&mut w); + assert_eq!(a.dialog.as_ref().map(Dialog::name), Some("saveChanges")); + assert_eq!(a.status_msg.as_ref().map(|(m, _)| m.as_str()), Some("Saved")); + assert_eq!((a.canvas.ime_preedit.as_str(), a.canvas.ime_preedit_range.clone()), ("ni", Some(0..2))); + assert!(!a.canvas.scroll_to_caret && a.canvas.nav_to_caret); + assert_eq!(a.ui.tab, "Insert"); + assert!(w.dialog.is_none() && w.tab == crate::DEFAULT_TAB); + assert_eq!((a.session.document_id(), a.session.rev(), a.ui.recent_count), (doc, rev, 7)); + assert_eq!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), "Kept"); + } +} diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index ff29549e5b..ab497a0de1 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -1,373 +1,1373 @@ -//! View › Window across WordCraft windows (#322): Switch Windows, Arrange All, View Side by Side -//! and Synchronous Scrolling. +//! One native window per open document. Each window is an egui viewport, driven by +//! [`craft_window::Viewports`]: one window draws in the root viewport, every other one in an +//! immediate viewport of its own, and each draws the whole interface (title bar, ribbon, page, +//! status bar, dialogs) for its own document. [`WordApp`]'s fields hold the drawn window's state +//! ([`crate::window_ui`]); the other windows keep theirs in their [`Window`]. Between frames the +//! fields hold the active window's (the one focused last), so the control channel, the MCP +//! bridge and the host's menus act on it. //! -//! Every desktop window is its own process with one document. The host (the desktop app) gives -//! the UI a [`WindowHost`]: the other running windows (a registry in the settings folder) and a -//! small authenticated channel to send them a [`PeerMessage`]. The engine's `view.*` window -//! commands only decide and record what to do (`Session::windows`); [`request`] carries it out -//! and [`poll`] applies what other windows ask of this one. Without a host (web, tests) the -//! commands are disabled. +//! A frame: [`WordApp::logic`] does the app-wide work once (the control channel, recovery, +//! timers, printing). [`WordApp::ui`] then draws the windows in turn: for each it swaps the +//! window's state in, makes its document the session's active one, does the window's own work +//! (its file dialog, AutoSave, dropped files, Read Aloud, requests queued for it) and draws it. +//! Last, the active window's state comes back. +//! +//! Which window shows what follows the session ([`WordApp::sync_document`]): a document made +//! active that no window shows (New, Open) gets a new window, focused and cascaded from the last +//! one; a document a window shows brings that window to the front; a window whose document closed +//! closes, and the window focused before it comes forward. Opening a file replaces a blank +//! document nobody used in its own window, and the last window stays open with no document (the +//! empty window the Start screen also leaves). The web app has one window and switches documents +//! in it ([`crate::documents`]). +//! +//! View › New Window opens another window on a document. Each window on it has its own view of +//! it (selection, zoom, view mode, panes: [`WindowView`]); the session holds the drawn window's, +//! and the others keep theirs in their [`Window`]. What the app keeps per document +//! (AutoSave's file, comment drafts) stays in one place, shared by its windows. Closing one of a +//! document's windows leaves the document open and asks nothing; closing its last window closes +//! it. -use egui::{Rect, Vec2, pos2, vec2}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use wordcraft_engine::cmd::view::OtherWindow; +use std::collections::HashMap; -use crate::WordApp; +use craft_window::{Document, Frame, Viewports, WindowEvent, WindowId, WindowList, WindowMenu}; +use egui::{ViewportCommand, ViewportId}; +use serde_json::{Value, json}; +use wordcraft_engine::WindowView; -/// What one window asks of another. Coordinates and sizes are egui points. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "camelCase")] -pub enum PeerMessage { - /// Are you there? (The registry's liveness check; never reaches the UI.) - Ping, - /// Come to the front. - Focus, - /// Move and resize the window's frame to this rect. - Place { x: f32, y: f32, width: f32, height: f32 }, - /// View Side by Side with the sender turned on or off. - SideBySide { on: bool }, - /// The sender turned Synchronous Scrolling on or off. - SyncScroll { on: bool }, - /// The sender scrolled by this many document points (positive: down). - Scroll { dy: f32 }, +use crate::{WordApp, documents, window_ui::WindowUi}; + +pub(crate) mod arrange; + +/// The window title for a document called `name`. +pub(crate) fn title(name: &str, dirty: bool) -> String { + format!("{name}{} - WordCraft", if dirty { " •" } else { "" }) } -/// Positions farther from the origin than this, and larger sizes, are refused. -const MAX_COORD: f32 = 32768.0; -/// Larger scroll steps are refused. -const MAX_SCROLL: f32 = 1.0e6; +/// An open window. +pub(crate) struct Window { + /// The app's key for the window. A window made while the windows are being drawn gets its + /// [`WindowId`] once the frame's drawing ends. + key: u64, + id: Option, + /// The document it shows. + doc: u64, + /// Its state while another window's is in [`WordApp`]'s fields; `None` for that window. + ui: Option>, + /// Requests commands made for its document while another window was drawn + /// ([`WordApp::handle_request`]), handled when it draws next. + requests: Vec, + /// The title it last drew, for its viewport. + title: String, + /// Its view of the document (caret, zoom, view mode…) while another window on the same + /// document is drawn. `None` while the session holds it: for the drawn window, and for the + /// window on each other document whose view is parked with that document. + view: Option, +} -impl PeerMessage { - /// The message if its numbers are usable: finite, positions within ±32768 points, sizes - /// positive and at most 32768, scroll steps at most 10⁶ points. Messages come from other - /// processes, so they are checked like any input. - pub fn sanitized(self) -> Option { - let ok = match &self { - PeerMessage::Place { x, y, width, height } => { - [*x, *y].iter().all(|c| c.is_finite() && c.abs() <= MAX_COORD) - && [*width, *height].iter().all(|c| c.is_finite() && *c >= 1.0 && *c <= MAX_COORD) +/// The app's windows. See the module documentation. +pub struct Windows { + /// Separate native windows (desktop). Off, one window shows every document in turn (web). + native: bool, + /// Every window, with its key, for the viewport driver: opening and focus order, positions. + /// Lent to the driver while the windows draw. + list: WindowList, + viewports: Option, + /// Open windows in opening order. + open: Vec, + /// The window whose state is in [`WordApp`]'s fields. + shown: u64, + next_key: u64, + /// The windows are being drawn: `list` is lent out, so windows opened or closed now join or + /// leave it when the drawing ends. + showing: bool, + /// Window keys, most recently focused first, as the drawing started. + recent: Vec, + /// A window to bring to the front when the drawing ends. + focus: Option, + /// View › Window › Switch Windows and the window titles, as the frame's drawing started. + menu: WindowMenu, + /// What a new window's viewport starts from: size, icon, app id ([`WordApp::set_window_builder`]). + builder: egui::ViewportBuilder, + /// [`ViewportInput`] is registered with egui. + input_plugin: bool, + /// The viewport driver draws the windows this frame, and titles them. + driven: bool, + /// View › Window: Side by Side and Synchronous Scrolling (#322). + arranged: arrange::State, +} + +impl Windows { + /// One window, showing document `doc`. + pub(crate) fn new(doc: u64) -> Windows { + let mut list = WindowList::new(); + let id = list.open(1); + let mut viewports = Viewports::new(Frame::Desktop); + viewports.set_root(Some(id)); + Windows { + native: cfg!(not(target_arch = "wasm32")), + list, + viewports: Some(viewports), + open: vec![Window { key: 1, id: Some(id), doc, ui: None, requests: Vec::new(), title: String::new(), view: None }], + shown: 1, + next_key: 2, + showing: false, + recent: vec![1], + focus: None, + menu: WindowMenu::default(), + builder: egui::ViewportBuilder::default().with_inner_size([1440.0, 920.0]).with_min_inner_size([760.0, 480.0]), + input_plugin: false, + driven: false, + arranged: arrange::State::default(), + } + } + + /// Whether documents get native windows of their own. + pub(crate) fn native(&self) -> bool { + self.native + } + + /// One window for every document, as in the web app. + #[cfg(test)] + pub(crate) fn single(&mut self) { + self.native = false; + } + + /// Whether the viewport driver titles the windows (else the drawn window sends its title). + pub(crate) fn driven(&self) -> bool { + self.driven + } + + /// The key of the window whose state is in [`WordApp`]'s fields. + pub(crate) fn shown(&self) -> u64 { + self.shown + } + + fn get(&self, key: u64) -> Option<&Window> { + self.open.iter().find(|w| w.key == key) + } + + fn get_mut(&mut self, key: u64) -> Option<&mut Window> { + self.open.iter_mut().find(|w| w.key == key) + } + + /// The window showing document `doc`, the most recently focused if several do. + fn of_doc(&self, doc: u64) -> Option { + self.recent_keys().into_iter().find(|&k| self.get(k).is_some_and(|w| w.doc == doc)) + } + + /// Open windows, most recently focused first; windows opened during this frame's drawing + /// come first. + fn recent_keys(&self) -> Vec { + let mut keys: Vec = self.open.iter().rev().filter(|w| w.id.is_none()).map(|w| w.key).collect(); + let listed: Vec = + if self.showing { self.recent.clone() } else { self.list.recent().filter_map(|id| self.list.get(id).copied()).collect() }; + keys.extend(listed.into_iter().filter(|&k| self.get(k).is_some())); + for w in &self.open { + if !keys.contains(&w.key) { + keys.push(w.key); } - PeerMessage::Scroll { dy } => dy.is_finite() && dy.abs() <= MAX_SCROLL, - _ => true, - }; - ok.then_some(self) - } -} - -/// A message and the window it came from. -#[derive(Clone, Debug, PartialEq)] -pub struct Envelope { - pub from: u64, - pub msg: PeerMessage, -} - -/// The host's side of View › Window: the other windows and a channel to them. Nothing here -/// blocks the UI thread: the list is refreshed and messages are sent in the background. -pub trait WindowHost { - /// This window's id in the registry. - fn id(&self) -> u64; - /// The other open windows as last seen, in a stable order. - fn others(&self) -> Vec; - /// Send `msg` to window `to` (queued; a window that has gone away is skipped). - fn send(&self, to: u64, msg: PeerMessage); - /// This window's title, for the other windows' Switch Windows lists. - fn publish(&self, title: &str); - /// Messages other windows sent since the last call. - fn incoming(&self) -> Vec; -} - -/// Each frame: keep `Session::windows` current, publish the title and apply messages from other -/// windows. -pub fn poll(app: &mut WordApp, ctx: &egui::Context) { - let Some(host) = app.services.windows.as_ref() else { return }; - let others = host.others(); - let title = app.title_stem(); - host.publish(&title); - let incoming = host.incoming(); - let w = &mut app.session.windows; - w.available = true; - w.others = others; - if let Some(p) = w.side_by_side - && !w.others.iter().any(|o| o.id == p) - { - // The other window closed. - w.side_by_side = None; - w.sync_scroll = false; - } - for env in incoming { - apply(app, ctx, env); - } - // This window scrolled (the canvas adds it up): the partner follows. - let dy = std::mem::take(&mut app.canvas.sync_out); - if let (Some(p), true) = (app.session.windows.side_by_side, app.session.windows.sync_scroll) - && dy != 0.0 - && let Some(host) = app.services.windows.as_ref() - { - host.send(p, PeerMessage::Scroll { dy }); - } -} - -/// What another window asked of this one. -fn apply(app: &mut WordApp, ctx: &egui::Context, env: Envelope) { - let w = &mut app.session.windows; - let partner = w.side_by_side == Some(env.from); - match env.msg { - PeerMessage::Ping => {} - PeerMessage::Focus => { - ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); - ctx.send_viewport_cmd(egui::ViewportCommand::Focus); - app.canvas.want_focus = true; } - PeerMessage::Place { x, y, width, height } => place(ctx, Rect::from_min_size(pos2(x, y), vec2(width, height))), - PeerMessage::SideBySide { on: true } => { - // A new partner replaces the old one, which is told. - if let Some(old) = w.side_by_side.filter(|old| *old != env.from) - && let Some(host) = app.services.windows.as_ref() - { - host.send(old, PeerMessage::SideBySide { on: false }); + keys + } + + /// The document of the window focused last before `key`'s, if any. + fn next_doc(&self, key: u64) -> Option { + if !self.native { + return None; + } + self.recent_keys().into_iter().find(|&k| k != key).and_then(|k| self.get(k)).map(|w| w.doc) + } + + /// The window's viewport. + fn viewport(&self, key: u64) -> ViewportId { + match (self.get(key).and_then(|w| w.id), &self.viewports) { + (Some(id), Some(v)) => v.viewport_id(id), + _ => ViewportId::ROOT, + } + } + + /// The title View › Window › Switch Windows gives the window, as the frame started. + fn menu_title(&self, key: u64) -> Option<&str> { + self.menu.title(self.get(key)?.id?) + } + + /// Bring the list up to date with the windows opened, closed and focused since it was last + /// lent out, and give the root viewport a window when its own closed. + fn settle(&mut self, ctx: Option<&egui::Context>) { + if self.showing { + return; + } + let gone: Vec = self.list.ids().filter(|id| !self.open.iter().any(|w| w.id == Some(*id))).collect(); + for id in gone { + self.list.close(id); + } + let active = self.list.active(); + let mut opened = false; + for w in self.open.iter_mut().filter(|w| w.id.is_none()) { + w.id = Some(self.list.open(w.key)); + opened = true; + } + let focus = self.focus.take().and_then(|k| self.get(k)).and_then(|w| w.id); + // A window opened behind the others (nothing asked to focus it) leaves the active one so. + if let Some(id) = focus.or(active.filter(|_| opened)) { + self.list.focus(id); + } + let Some(viewports) = self.viewports.as_mut() else { return }; + if !viewports.root().is_some_and(|r| self.list.contains(r)) + && let Some(next) = self.list.active() + { + match ctx { + Some(ctx) => viewports.adopt(ctx, &self.list, next), + None => viewports.set_root(Some(next)), } - w.side_by_side = Some(env.from); - w.sync_scroll = true; } - PeerMessage::SideBySide { on: false } if partner => { - w.side_by_side = None; - w.sync_scroll = false; + if let (Some(id), Some(ctx)) = (focus, ctx) { + viewports.focus(ctx, id); } - PeerMessage::SyncScroll { on } if partner => w.sync_scroll = on, - PeerMessage::Scroll { dy } if partner && w.sync_scroll => { - app.canvas.sync_in += dy; + if opened && let Some(ctx) = ctx { ctx.request_repaint(); } - _ => {} - } - ctx.request_repaint(); -} - -/// Carry out a window request a `view.*` command made (`{"windows": …}`). -pub fn request(app: &mut WordApp, req: &Value) { - let Some(what) = req.get("windows").and_then(Value::as_str) else { return }; - let (Some(host), Some(ctx)) = (app.services.windows.as_ref(), app.ctx.as_ref()) else { return }; - let id = |k: &str| req.get(k).and_then(Value::as_u64); - let area = ctx.input(|i| monitor_area(i.viewport())); - match what { - "focus" => { - if let Some(to) = id("id") { - host.send(to, PeerMessage::Focus); - } - } - "arrange" => { - let Some(area) = area else { return }; - let mut ids: Vec = app.session.windows.others.iter().map(|o| o.id).chain([host.id()]).collect(); - ids.sort_unstable(); - for (id, r) in ids.iter().zip(tile(ids.len(), area)) { - if *id == host.id() { - place(ctx, r); - } else { - host.send(*id, place_msg(r)); - } - } - } - "sideBySide" => { - if let Some(off) = id("off") { - host.send(off, PeerMessage::SideBySide { on: false }); - } - if let Some(with) = id("with") { - if let Some(area) = area { - let [left, right] = halves(area); - place(ctx, left); - host.send(with, place_msg(right)); - } - host.send(with, PeerMessage::SideBySide { on: true }); - } - } - "syncScroll" => { - if let (Some(with), Some(on)) = (id("with"), req.get("value").and_then(Value::as_bool)) { - host.send(with, PeerMessage::SyncScroll { on }); - } - } - _ => {} } } -fn place_msg(r: Rect) -> PeerMessage { - PeerMessage::Place { x: r.min.x, y: r.min.y, width: r.width(), height: r.height() } +/// Control-channel input for a window in a viewport of its own: eframe's raw-input hook +/// ([`WordApp::raw_input_hook`]) sees only the root viewport's input, and egui runs a plugin's +/// input hook for every viewport. On macOS it also reads Control-click as the secondary button +/// in those windows, as the raw-input hook does in the root's. +#[derive(Default)] +pub(crate) struct ViewportInput { + /// The viewport the queued events go to, on its next pass. + target: Option, + events: Vec, + /// A viewport whose pass got keyboard input this way, until its window has looked. + keys_for: Option, + control_click: HashMap, } -/// Move this window's frame to `r`: un-maximized, its content `r` less the frame's borders, and -/// allowed below the usual minimum size so two halves fit a small screen. Platforms that don't -/// let apps place windows (Wayland) ignore the position. -fn place(ctx: &egui::Context, r: Rect) { - let border = ctx.input(|i| { - let v = i.viewport(); - match (v.outer_rect, v.inner_rect) { - (Some(o), Some(inner)) => (o.size() - inner.size()).max(Vec2::ZERO), - _ => Vec2::ZERO, - } - }); - let inner = (r.size() - border).max(vec2(200.0, 150.0)); - ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(false)); - ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); - ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(false)); - ctx.send_viewport_cmd(egui::ViewportCommand::MinInnerSize(inner.min(crate::window_geometry::MIN_SIZE))); - ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(inner)); - ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(r.min)); -} - -/// The monitor this window is on, in points. egui reports the monitor's size but not where it -/// is, so this assumes monitors of that size laid out in a grid from the primary monitor at the -/// origin, and picks the cell under the window's centre. -pub fn monitor_area(v: &egui::ViewportInfo) -> Option { - let size = v.monitor_size.filter(|s| s.is_finite() && s.x >= 1.0 && s.y >= 1.0)?; - let centre = v.outer_rect.or(v.inner_rect).map(|r| r.center()).filter(|c| c.is_finite()).unwrap_or(egui::Pos2::ZERO); - let cell = |c: f32, s: f32| ((c / s).floor() * s).clamp(-MAX_COORD, MAX_COORD); - Some(Rect::from_min_size(pos2(cell(centre.x, size.x), cell(centre.y, size.y)), size)) -} - -/// Arrange All: `n` windows tiled over `area`. One or two windows stack top to bottom, like -/// Word; more fill a grid of ⌈√n⌉ columns, the last row's windows sharing its full width. -pub fn tile(n: usize, area: Rect) -> Vec { - let n = n.min(64); - if n == 0 { - return Vec::new(); +impl egui::Plugin for ViewportInput { + fn debug_name(&self) -> &'static str { + "wordcraft_viewport_input" } - let cols = if n <= 2 { 1 } else { (n as f32).sqrt().ceil() as usize }; - let rows = n.div_ceil(cols); - let h = area.height() / rows as f32; - (0..n) - .map(|i| { - let (row, col) = (i / cols, i % cols); - let in_row = if row + 1 == rows { n - row * cols } else { cols }; - let w = area.width() / in_row as f32; - Rect::from_min_size(pos2(area.min.x + col as f32 * w, area.min.y + row as f32 * h), vec2(w, h)) + + fn input_hook(&mut self, _ctx: &egui::Context, input: &mut egui::RawInput) { + let viewport = input.viewport_id; + if viewport == ViewportId::ROOT { + return; + } + if cfg!(target_os = "macos") { + crate::control_click_as_secondary(&mut input.events, self.control_click.entry(viewport).or_default()); + } + if self.target != Some(viewport) || self.events.is_empty() { + return; + } + self.target = None; + let events = std::mem::take(&mut self.events); + if events.iter().any(|e| matches!(e, egui::Event::Key { .. } | egui::Event::Text(_))) { + input.focused = true; + self.keys_for = Some(viewport); + } + input.events.extend(events); + } +} + +impl WordApp { + /// How many windows are open. + pub fn window_count(&self) -> usize { + if self.windows.native { self.windows.open.len() } else { 1 } + } + + /// The egui viewport of the active window, the one focused last: control input, + /// `ui.screenshot`, `ui.resize` and the saved window geometry are its. The root viewport + /// when the windows don't have viewports of their own (the web app, embedded viewports). + pub fn active_viewport(&self) -> ViewportId { + if !self.windows.native || !self.windows.driven { + return ViewportId::ROOT; + } + let key = self.windows.recent_keys().first().copied().unwrap_or(self.windows.shown); + self.windows.viewport(key) + } + + /// The active window's viewport and surface, for `ui.screenshot`: the root's from this + /// frame's raw input, another window's from the input it last drew with (the host's raw-input + /// hook sees only the root's). + pub(crate) fn active_surface_geometry(&self, ctx: &egui::Context) -> (ViewportId, Option) { + let viewport = self.active_viewport(); + if viewport == ViewportId::ROOT { + return (viewport, self.surface_geometry); + } + let (info, screen_rect, max_texture_side) = ctx.input_for(viewport, |i| (i.viewport().clone(), i.raw.screen_rect, i.raw.max_texture_side)); + (viewport, crate::control::SurfaceGeometry::from_viewport(&info, screen_rect, max_texture_side, ctx)) + } + + /// The viewport new windows start from: the host's size, icon and app id. Their title and + /// place are the app's. + pub fn set_window_builder(&mut self, builder: egui::ViewportBuilder) { + self.windows.builder = builder; + } + + /// Whether a close request for the root viewport closes only the window drawn there: true + /// while other windows are open. [`WordApp::ui`] closes it (a [`WindowEvent::CloseRequested`]), + /// and the host keeps the app running. + pub fn closes_a_window(&self) -> bool { + self.window_count() > 1 && !self.quit_requested + } + + /// Quit the app (the app menu's Quit): ask about every document with unsaved work, each in + /// its window, as [`WordApp::close_requested`] does. True when the app may quit now; the + /// host closes it once `quit_requested` is set. + pub fn quit(&mut self) -> bool { + let go = self.close_requested(); + if go { + self.quit_requested = true; + } + go + } + + /// Put the drawn window's state away and draw window `key`, with its document active. False + /// when there is no such window, or its document has closed ([`WordApp::prune_windows`] + /// closes it then); the drawn window stays. + pub(crate) fn enter_window(&mut self, key: u64) -> bool { + if key == self.windows.shown { + return true; + } + let session = &self.session; + let Some(w) = self.windows.get_mut(key).filter(|w| w.ui.as_ref().is_some_and(|ui| session.is_open(ui.shown_document))) else { + return false; + }; + let Some(mut state) = w.ui.take() else { return false }; + let view = w.view.take(); + let from = self.shown_document; + self.swap_window(&mut state); + let left = std::mem::replace(&mut self.windows.shown, key); + if let Some(w) = self.windows.get_mut(left) { + w.doc = state.shown_document; + w.ui = Some(state); + } + let to = self.shown_document; + self.carry_document_state(from, to); + if let Some(view) = view { + self.swap_view_in(key, to, view); + } + true + } + + /// Window `key`, on document `doc` (now the session's active one), brings its own view of the + /// document: the view the session holds goes to the window on `doc` it belongs to (the one + /// without a view of its own), and `view` takes its place, made valid for edits made since. + fn swap_view_in(&mut self, key: u64, doc: u64, view: WindowView) { + let held = self.session.take_view(); + if let Some(owner) = self.windows.open.iter_mut().find(|w| w.key != key && w.doc == doc && w.view.is_none()) { + owner.view = Some(held); + } + self.session.put_view(view); + } + + /// Open a new window for open document `doc`, focused, and draw it. The window that was drawn + /// leaves the File page: whatever it was opening is open. `view` is the new window's view of + /// a document another window shows (View › New Window); `None` for a document no window + /// shows yet. + fn open_window(&mut self, doc: u64, view: Option) { + let key = self.windows.next_key; + self.windows.next_key = self.windows.next_key.saturating_add(1); + let mut state = Box::new(WindowUi::new(doc)); + // Faces borrowed for the interface (non-Latin file names) stay installed. + state.canvas.ime_faces = self.canvas.ime_faces.clone(); + self.ui.backstage = false; + self.windows.open.push(Window { key, id: None, doc, ui: Some(state), requests: Vec::new(), title: String::new(), view }); + self.enter_window(key); + self.focus_window(key); + } + + /// View › New Window: another window on the drawn window's document, with the same caret, + /// zoom and view, focused and cascaded from the others. Each window then has its own caret + /// and view; the document, its undo history and its save state are shared. In one window + /// (the web app) and with no document open, nothing happens. + pub(crate) fn new_window(&mut self) { + if !self.windows.native || !self.has_document() { + return; + } + let view = self.session.copy_view(); + self.open_window(self.shown_document, Some(view)); + } + + /// The window that comes forward when the drawn window closes and other windows show its + /// document, which then stays open: the one of all the others focused last. `None` when the + /// drawn window is its document's only one. + pub(crate) fn other_window_on_document(&self) -> Option { + let shown = self.windows.shown; + let doc = self.windows.get(shown)?.doc; + if !self.windows.native || !self.windows.open.iter().any(|w| w.key != shown && w.doc == doc) { + return None; + } + self.windows.recent_keys().into_iter().find(|&k| k != shown) + } + + /// Whether command `id` (File › Close) closes the drawn window alone, asking nothing: other + /// windows show its document. Quitting closes documents, not windows. + pub(crate) fn closes_only_its_window(&self, id: &str, params: &Value) -> bool { + id == "file.close" && params.get("quit").and_then(Value::as_bool) != Some(true) && self.other_window_on_document().is_some() + } + + /// Close the drawn window alone: other windows show its document, which stays open as it is. + pub(crate) fn close_window_only(&mut self) -> bool { + let Some(next) = self.other_window_on_document() else { return false }; + self.close_shown_window(next); + true + } + + /// The drawn window's document has closed: draw window `to` and close this one. + fn close_shown_window(&mut self, to: u64) { + let closing = self.windows.shown; + if !self.enter_window(to) { + return; + } + self.windows.open.retain(|w| w.key != closing); + self.focus_window(to); + } + + /// Bring window `key` to the front (now, or once the frame's drawing ends) and make it the + /// active one. + pub(crate) fn focus_window(&mut self, key: u64) { + self.windows.focus = Some(key); + self.settle_windows(); + } + + /// [`Windows::settle`]; a window that moved into the root viewport keeps its place on the + /// page (the page's scroll state is the viewport's). + fn settle_windows(&mut self) { + let root = |w: &Windows| w.viewports.as_ref().and_then(Viewports::root); + let before = root(&self.windows); + self.windows.settle(self.ctx.as_ref()); + let Some(adopted) = root(&self.windows).filter(|r| before.is_some_and(|b| b != *r)) else { return }; + let Some(key) = self.windows.list.get(adopted).copied() else { return }; + if key == self.windows.shown { + self.canvas.scroll_request = Some(self.canvas.scroll_offset); + } else if let Some(w) = self.windows.get_mut(key).and_then(|w| w.ui.as_mut()) { + w.canvas.scroll_request = Some(w.canvas.scroll_offset); + } + } + + /// Keep a window for every open document: windows whose document closed (an agent closed it) + /// close, and documents with no window (an agent made one while another was active) get one + /// behind the others. The active document is left to [`WordApp::sync_document`]. + pub(crate) fn prune_windows(&mut self) { + let shown = self.windows.shown; + let session = &self.session; + let before = self.windows.open.len(); + self.windows.open.retain(|w| w.key == shown || session.is_open(w.doc)); + let mut changed = self.windows.open.len() != before; + let active = self.session.document_id(); + for d in self.session.documents() { + if d.id == active || Some(d.id) == self.no_document || d.id == self.shown_document || self.windows.of_doc(d.id).is_some() { + continue; + } + let key = self.windows.next_key; + self.windows.next_key = self.windows.next_key.saturating_add(1); + let mut state = Box::new(WindowUi::new(d.id)); + state.canvas.ime_faces = self.canvas.ime_faces.clone(); + self.windows.open.push(Window { key, id: None, doc: d.id, ui: Some(state), requests: Vec::new(), title: String::new(), view: None }); + changed = true; + } + if changed { + self.settle_windows(); + } + } + + /// [`WordApp::sync_document`] with native windows: the session's active document `id` isn't + /// the drawn window's. Another open document shows in its window, which comes to the front, + /// or in a new window; when the drawn window's document has closed, its window closes and the + /// one showing `id` comes forward. The Start screen's empty window stays beside the window + /// that shows `id`. False when the window itself is to show `id` instead: its document was + /// replaced, or it is the Start screen's empty window and no other window shows `id`. + pub(crate) fn follow_in_windows(&mut self, id: u64) -> bool { + let left = self.shown_document; + let stand_in = self.no_document == Some(left); + let other = self.windows.of_doc(id).filter(|&k| k != self.windows.shown); + if self.session.is_open(left) { + match other { + Some(key) if self.enter_window(key) => self.focus_window(key), + _ if stand_in => return false, + _ => self.open_window(id, None), + } + return true; + } + if let Some(key) = other { + self.close_shown_window(key); + return true; + } + false + } + + /// The drawn window now shows document `id` (its own was replaced). + pub(crate) fn window_shows(&mut self, id: u64) { + let shown = self.windows.shown; + if let Some(w) = self.windows.get_mut(shown) { + w.doc = id; + } + } + + /// Close the active document. With windows, its window closes and the window focused before + /// it comes forward; else the window shows the next document. The session leaves a new blank + /// document when it was the last. + pub(crate) fn close_shown_document(&mut self) -> Result<(), String> { + let doc = self.session.document_id(); + let next = self.windows.next_doc(self.windows.shown); + self.session.close_document(doc).map_err(|e| e.to_string())?; + if let Some(next) = next + && self.session.is_open(next) + && let Err(e) = self.session.switch_document(next) + { + log::warn!("closing document {doc}: couldn't show document {next}: {e}"); + } + self.sync_document(); + Ok(()) + } + + /// A request made for open document `document`, which another window shows: that window + /// handles it when it draws next (a dialog opens there). The active window and document stay + /// as they are, so the control channel and agents go on working where they were. False when + /// no other window shows the document (or windows aren't native). + pub(crate) fn queue_for_window(&mut self, document: u64, req: &Value) -> bool { + if !self.windows.native { + return false; + } + let Some(key) = self.windows.of_doc(document).filter(|&k| k != self.windows.shown) else { return false }; + if let Some(w) = self.windows.get_mut(key) { + w.requests.push(req.clone()); + } + if let Some(ctx) = &self.ctx { + ctx.request_repaint(); + } + true + } + + /// View › Window › Switch Windows with native windows: every window by its title (two + /// windows on one document as `Name:1`, `Name:2`) with its key, the active one marked, + /// unsaved ones with the title bar's dot. Choosing one brings it to the front + /// ([`WordApp::show_window`]). + pub(crate) fn switch_windows_entries(&self) -> Vec<(u64, String, bool)> { + self.windows + .menu + .entries() + .iter() + .filter_map(|e| { + let w = self.windows.open.iter().find(|w| w.id == Some(e.id))?; + Some((w.key, format!("{}{}", e.title, if e.dirty { " •" } else { "" }), w.key == self.windows.shown)) + }) + .collect() + } + + /// Bring window `key` to the front and make it the active one, with its document. + pub(crate) fn show_window(&mut self, key: u64) { + if self.enter_window(key) { + self.focus_window(key); + } + } + + /// The drawn window's title: its document's name as View › Window › Switch Windows gives it. + pub(crate) fn window_title(&self) -> String { + if !self.has_document() { + return "WordCraft".to_string(); + } + let name = self.windows.menu_title(self.windows.shown).map(str::to_string).unwrap_or_else(|| self.title_stem()); + title(&name, self.session.dirty) + } + + /// Draw every window, each in its viewport (see the module documentation). + pub(crate) fn windows_ui(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + self.prune_windows(); + self.settle_windows(); + self.windows.menu = self.window_menu(&self.windows.list); + // A backend that can't open native windows (egui embeds them in the root: tests, + // `ui_shot`) shows the active window alone. Embedded, every window would draw the same + // widgets in one viewport. + self.windows.driven = !ctx.embed_viewports() && self.windows.viewports.is_some(); + if !self.windows.driven { + let active = self.windows.recent_keys().first().copied().unwrap_or(self.windows.shown); + self.draw_window(ui, active); + self.after_windows(); + return; + } + let Some(mut viewports) = self.windows.viewports.take() else { return }; + let mut list = std::mem::take(&mut self.windows.list); + self.windows.recent = list.recent().filter_map(|id| list.get(id).copied()).collect(); + let titles: HashMap = self.windows.open.iter().map(|w| (w.key, w.title.clone())).collect(); + let base = self.windows.builder.clone(); + self.windows.showing = true; + let events = viewports.show( + ui, + &mut list, + |_, key| match titles.get(key).filter(|t| !t.is_empty()) { + Some(t) => base.clone().with_title(t.clone()), + None => base.clone().with_title("WordCraft"), + }, + |ui, _, key| self.draw_window(ui, *key), + ); + self.windows.showing = false; + self.windows.list = list; + self.windows.viewports = Some(viewports); + self.window_events(&ctx, &events); + self.after_windows(); + } + + /// After the frame's drawing: windows opened, closed or focused meanwhile settle, and the + /// active window's state comes back. + fn after_windows(&mut self) { + self.prune_windows(); + self.settle_windows(); + if let Some(&active) = self.windows.recent_keys().first() { + self.enter_window(active); + } + } + + /// View › Window › Switch Windows for the windows in `list`. + fn window_menu(&self, list: &WindowList) -> WindowMenu { + let docs = self.session.documents(); + let doc_of: HashMap = self.windows.open.iter().map(|w| (w.key, w.doc)).collect(); + WindowMenu::new(list, |_, key| { + let doc = doc_of.get(key).copied().unwrap_or_default(); + match docs.iter().find(|d| d.id == doc) { + Some(d) => Document { key: doc, name: documents::display_title(d), dirty: d.dirty }, + None => Document { key: doc, name: String::new(), dirty: false }, + } }) - .collect() -} + } -/// View Side by Side: the left and right halves of `area`. -pub fn halves(area: Rect) -> [Rect; 2] { - let mid = area.center().x; - [Rect::from_min_max(area.min, pos2(mid, area.max.y)), Rect::from_min_max(pos2(mid, area.min.y), area.max)] + /// Draw window `key` into its viewport's `ui`. + fn draw_window(&mut self, ui: &mut egui::Ui, key: u64) { + if !self.enter_window(key) { + return; + } + let ctx = ui.ctx().clone(); + let queued = self.windows.get_mut(key).map(|w| std::mem::take(&mut w.requests)).unwrap_or_default(); + for req in queued { + self.handle_request(&req); + } + self.control_keys_arrived(&ctx); + self.window_work(&ctx); + self.window_input(&ctx); + if self.windows.shown != key { + return; + } + self.window_ui(ui); + arrange::forward_scroll(self, &ctx); + // Drawing can close the window or bring another forward: the fields then hold another + // window's state. + if self.windows.shown != key { + return; + } + let title = self.sent_title.clone(); + if let Some(w) = self.windows.get_mut(key) { + w.title = title; + } + } + + /// The windows' focus changes and close requests from this frame. + fn window_events(&mut self, ctx: &egui::Context, events: &[WindowEvent]) { + for event in events { + let WindowEvent::CloseRequested(id) = *event else { continue }; + let Some(key) = self.windows.list.get(id).copied() else { continue }; + // The last window's close button quits: the host asks first ([`WordApp::close_requested`]). + if self.windows.open.len() <= 1 || self.quit_requested { + continue; + } + if self.windows.viewports.as_ref().and_then(Viewports::root) == Some(id) { + ctx.send_viewport_cmd_to(ViewportId::ROOT, ViewportCommand::CancelClose); + } + self.close_window(key); + } + } + + /// The close button of window `key` (not the last one): File › Close in that window, which + /// asks about unsaved changes first. + pub(crate) fn close_window(&mut self, key: u64) { + if !self.enter_window(key) { + return; + } + self.focus_window(key); + if !self.has_document() { + // An empty window beside others: nothing to ask about. + let _ = self.close_shown_document(); + return; + } + if let Err(e) = self.run("file.close", json!({})) { + log::warn!("closing a window: {e}"); + } + } + + /// The control channel's next input, for the active window when it has a viewport of its own: + /// handed to [`ViewportInput`], which gives it to that viewport's next pass. + pub(crate) fn send_control_input(&mut self, ctx: &egui::Context) { + if self.synthetic.is_empty() || self.control_input_is_roots(ctx) { + return; + } + if !self.windows.input_plugin { + ctx.add_plugin(ViewportInput::default()); + self.windows.input_plugin = true; + } + self.settle_windows(); + let target = self.active_viewport(); + // Input queued for a window that has closed since (or moved into the root viewport) + // would wait forever. + let live: Vec = self.windows.open.iter().map(|w| self.windows.viewport(w.key)).filter(|&v| v != ViewportId::ROOT).collect(); + ctx.with_plugin(|p: &mut ViewportInput| { + if p.target.is_some_and(|t| !live.contains(&t)) { + p.target = None; + p.events.clear(); + } + }); + let waiting = ctx.with_plugin(|p: &mut ViewportInput| !p.events.is_empty()).unwrap_or(true); + if waiting { + return; + } + let Some(events) = self.next_synthetic() else { return }; + ctx.with_plugin(|p: &mut ViewportInput| { + p.target = Some(target); + p.events = events; + }); + ctx.request_repaint_of(target); + } + + /// Whether control input goes in through the root viewport's raw input: one window, the + /// active window drawn there, or viewports embedded in the root (tests). + pub(crate) fn control_input_is_roots(&self, ctx: &egui::Context) -> bool { + !self.windows.native || ctx.embed_viewports() || self.active_viewport() == ViewportId::ROOT + } + + /// Keys from the control channel reached this window's viewport: the page counts as focused + /// when its widget has the keyboard, as [`WordApp::raw_input_hook`] does for the root. + fn control_keys_arrived(&mut self, ctx: &egui::Context) { + if !self.windows.input_plugin { + return; + } + let viewport = ctx.viewport_id(); + let arrived = ctx + .with_plugin(|p: &mut ViewportInput| { + let hit = p.keys_for == Some(viewport); + if hit { + p.keys_for = None; + } + hit + }) + .unwrap_or(false); + if arrived && let Some((id, _)) = self.canvas.widget_id { + self.canvas.focused = ctx.memory(|m| m.has_focus(id)); + } + } + + /// The id of the drawn window's own `name` in egui's memory, which every viewport shares: a + /// search box's text, a gallery's first tile. + pub(crate) fn window_id(&self, name: &'static str) -> egui::Id { + egui::Id::unique(name).with(self.windows.shown) + } + + /// The windows other than the drawn one, for state every window contributes to (the faces + /// borrowed for the interface). + pub(crate) fn other_windows(&self) -> impl Iterator { + self.windows.open.iter().filter_map(|w| w.ui.as_deref()) + } } #[cfg(test)] mod tests { - use super::*; - use serde_json::json; use std::cell::RefCell; - use std::rc::Rc; + use std::collections::HashMap; - #[test] - fn tiles_cover_the_monitor_without_overlap() { - let area = Rect::from_min_size(pos2(1920.0, 0.0), vec2(1920.0, 1080.0)); - assert!(tile(0, area).is_empty()); - assert_eq!(tile(1, area), vec![area]); - // Two windows stack, like Word's Arrange All. - assert_eq!( - tile(2, area), - vec![Rect::from_min_size(pos2(1920.0, 0.0), vec2(1920.0, 540.0)), Rect::from_min_size(pos2(1920.0, 540.0), vec2(1920.0, 540.0))] - ); - for n in 3..=10 { - let tiles = tile(n, area); - assert_eq!(tiles.len(), n); - let covered: f32 = tiles.iter().map(|r| r.area()).sum(); - assert!((covered - area.area()).abs() < 1.0, "{n} windows cover the monitor"); - for (i, a) in tiles.iter().enumerate() { - assert!(area.expand(0.01).contains_rect(*a), "{n}: {a:?}"); - for b in &tiles[i + 1..] { - assert!(a.intersect(*b).area() < 0.01, "{n}: {a:?} overlaps {b:?}"); - } - } - } - assert_eq!(tile(10_000, area).len(), 64, "hostile counts are capped"); - let [l, r] = halves(area); - assert_eq!( - (l, r), - (Rect::from_min_size(pos2(1920.0, 0.0), vec2(960.0, 1080.0)), Rect::from_min_size(pos2(2880.0, 0.0), vec2(960.0, 1080.0))) - ); - // The monitor is the one under the window's centre. - let info = |x: f32| egui::ViewportInfo { - monitor_size: Some(vec2(1920.0, 1080.0)), - outer_rect: Some(Rect::from_min_size(pos2(x, 100.0), vec2(800.0, 600.0))), + use egui::{RawInput, ViewportCommand, ViewportEvent, ViewportId, ViewportIdMap, ViewportInfo}; + use serde_json::json; + use wordcraft_doc::StoryRef; + use wordcraft_engine::Session; + + use crate::{Services, WordApp}; + + thread_local! { + /// What the test backend tells each viewport about itself (focus, close requests). + static INFOS: RefCell> = RefCell::new(ViewportIdMap::default()); + /// Input events for each viewport's next pass. + static EVENTS: RefCell>> = RefCell::new(HashMap::new()); + /// The viewports that drew in the last frame, in order. + static DRAWN: RefCell> = const { RefCell::new(Vec::new()) }; + /// Viewport commands the app sent, by viewport. + static COMMANDS: RefCell>> = RefCell::new(HashMap::new()); + /// Textures uploaded (page rasters, the font atlas) since the counter was reset. + static UPLOADS: RefCell = const { RefCell::new(0) }; + /// Each viewport's last input-method output. + static IME: RefCell>> = RefCell::new(HashMap::new()); + } + + fn input(id: ViewportId) -> RawInput { + let mut raw = RawInput { + viewport_id: id, + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0))), ..Default::default() }; - assert_eq!(monitor_area(&info(100.0)), Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1920.0, 1080.0)))); - assert_eq!(monitor_area(&info(2000.0)), Some(area)); - assert_eq!(monitor_area(&egui::ViewportInfo::default()), None); - assert_eq!(monitor_area(&egui::ViewportInfo { monitor_size: Some(vec2(f32::NAN, 1.0)), ..Default::default() }), None); + raw.viewports = INFOS.with(|i| i.borrow().clone()); + raw.focused = raw.viewports.entry(id).or_default().focused.unwrap_or(false); + raw.events = EVENTS.with(|e| e.borrow_mut().remove(&id).unwrap_or_default()); + raw } - #[test] - fn hostile_messages_are_refused() { - let place = |x: f32, w: f32| PeerMessage::Place { x, y: 0.0, width: w, height: 600.0 }.sanitized(); - assert!(place(10.0, 800.0).is_some()); - assert!(place(f32::NAN, 800.0).is_none()); - assert!(place(1e9, 800.0).is_none()); - assert!(place(0.0, -5.0).is_none()); - assert!(place(0.0, f32::INFINITY).is_none()); - assert!(PeerMessage::Scroll { dy: f32::NAN }.sanitized().is_none()); - assert!(PeerMessage::Scroll { dy: 1e30 }.sanitized().is_none()); - assert_eq!(PeerMessage::Scroll { dy: -12.5 }.sanitized(), Some(PeerMessage::Scroll { dy: -12.5 })); - let parsed: PeerMessage = serde_json::from_value(json!({"type": "sideBySide", "on": true})).unwrap(); - assert_eq!(parsed, PeerMessage::SideBySide { on: true }); - assert!(serde_json::from_value::(json!({"type": "engine.execute"})).is_err()); + fn keep(out: egui::FullOutput, id: ViewportId) { + DRAWN.with(|d| d.borrow_mut().push(id)); + UPLOADS.with(|u| *u.borrow_mut() += out.textures_delta.set.len()); + IME.with(|i| i.borrow_mut().insert(id, out.platform_output.ime.clone())); + for (v, o) in &out.viewport_output { + COMMANDS.with(|c| c.borrow_mut().entry(*v).or_default().extend(o.commands.iter().cloned())); + } + out.drop_without_applying_deltas(); } - /// A host that records what was sent and hands out queued messages. - #[derive(Clone, Default)] - struct Fake { - sent: Rc>>, - inbox: Rc>>, - } - - impl WindowHost for Fake { - fn id(&self) -> u64 { - 1 - } - fn others(&self) -> Vec { - vec![OtherWindow { id: 2, title: "Notes".into() }, OtherWindow { id: 3, title: "Draft".into() }] - } - fn send(&self, to: u64, msg: PeerMessage) { - self.sent.borrow_mut().push((to, msg)); - } - fn publish(&self, _title: &str) {} - fn incoming(&self) -> Vec { - self.inbox.borrow_mut().drain(..).collect() - } - } - - #[test] - fn side_by_side_scrolls_with_its_partner_only() { - let fake = Fake::default(); - let services = crate::Services { windows: Some(Box::new(fake.clone())), ..Default::default() }; - let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), services); + /// A backend with native viewports, as eframe's: each immediate viewport runs a pass of its + /// own, with its own input. + fn backend() -> egui::Context { + INFOS.with(|i| i.borrow_mut().clear()); + EVENTS.with(|e| e.borrow_mut().clear()); + COMMANDS.with(|c| c.borrow_mut().clear()); + UPLOADS.with(|u| *u.borrow_mut() = 0); let ctx = egui::Context::default(); - app.ctx = Some(ctx.clone()); - poll(&mut app, &ctx); - assert!(app.session.windows.available); - assert_eq!(app.session.windows.others.len(), 2); - app.run("view.sideBySide", json!({"window": 3})).unwrap(); - // No monitor size headless: nothing is placed, but the partner hears about it. - assert_eq!(fake.sent.borrow_mut().drain(..).collect::>(), [(3, PeerMessage::SideBySide { on: true })]); - app.run("view.switchWindows", json!({"window": 2})).unwrap(); - assert_eq!(fake.sent.borrow_mut().pop(), Some((2, PeerMessage::Focus))); - // Scrolls from the partner move this window; anyone else's are ignored. - fake.inbox - .borrow_mut() - .extend([Envelope { from: 3, msg: PeerMessage::Scroll { dy: 40.0 } }, Envelope { from: 2, msg: PeerMessage::Scroll { dy: 1000.0 } }]); - poll(&mut app, &ctx); - assert_eq!(app.canvas.sync_in, 40.0); - // This window's own scrolling goes to the partner. - app.canvas.sync_out = -15.0; - poll(&mut app, &ctx); - assert_eq!(fake.sent.borrow_mut().pop(), Some((3, PeerMessage::Scroll { dy: -15.0 }))); - // Turned off on the partner's side: no more syncing. - fake.inbox.borrow_mut().push(Envelope { from: 3, msg: PeerMessage::SideBySide { on: false } }); - poll(&mut app, &ctx); - assert_eq!((app.session.windows.side_by_side, app.session.windows.sync_scroll), (None, false)); - app.canvas.sync_out = 5.0; - poll(&mut app, &ctx); - assert!(fake.sent.borrow().is_empty()); - assert!(app.run("view.syncScroll", json!({})).is_err()); + ctx.set_embed_viewports(false); + egui::Context::set_immediate_viewport_renderer(|ctx, viewport| { + let id = viewport.ids.this; + let mut raw = input(id); + raw.viewports.entry(id).or_default().parent = Some(viewport.ids.parent); + let mut cb = viewport.viewport_ui_cb; + keep(ctx.run_ui(raw, |ui| cb(ui)), id); + }); + ctx + } + + /// One frame of every window. + fn frame(ctx: &egui::Context, a: &mut WordApp) { + DRAWN.with(|d| d.borrow_mut().clear()); + let mut raw = input(ViewportId::ROOT); + a.raw_input_hook(ctx, &mut raw); + let out = ctx.run_ui(raw, |ui| { + a.logic(ui.ctx()); + a.ui(ui); + }); + keep(out, ViewportId::ROOT); + INFOS.with(|i| i.borrow_mut().values_mut().for_each(|v| v.events.clear())); + } + + fn frames(ctx: &egui::Context, a: &mut WordApp, n: usize) { + for _ in 0..n { + frame(ctx, a); + } + } + + /// Focus viewport `id` alone, as the desktop does when a window is clicked. + fn focus(id: ViewportId) { + INFOS.with(|i| { + let mut i = i.borrow_mut(); + for v in i.values_mut() { + v.focused = Some(false); + } + i.entry(id).or_default().focused = Some(true); + }); + } + + fn sent(id: ViewportId, cmd: &ViewportCommand) -> bool { + COMMANDS.with(|c| c.borrow().get(&id).is_some_and(|v| v.contains(cmd))) + } + + fn app(text: &str) -> WordApp { + let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); + a.run("text.insert", json!({"text": text})).unwrap(); + a + } + + fn text(a: &WordApp) -> String { + a.session.doc.plain_text(StoryRef::Body).trim().to_string() + } + + fn prompt(a: &WordApp) -> Option<&'static str> { + a.dialog.as_ref().map(|d| d.name()) + } + + /// New opens the document in a second native window, focused, with a fresh interface; the + /// first keeps its document, its ribbon tab and its open dialog, and gets them back when it + /// is focused again. + #[test] + fn new_opens_a_second_window_and_each_keeps_its_state() { + let ctx = backend(); + let mut a = app("First"); + let first = a.session.document_id(); + frames(&ctx, &mut a, 3); + a.ui.tab = "Insert".into(); + a.run("ui.dialog", json!({"name": "paragraph"})).unwrap(); + assert_eq!(prompt(&a), Some("paragraph")); + + a.run("file.new", json!({})).unwrap(); + let second = a.session.document_id(); + assert_ne!(second, first); + assert_eq!(a.window_count(), 2); + assert!(a.dialog.is_none() && a.ui.tab == crate::DEFAULT_TAB, "the new window starts afresh"); + frames(&ctx, &mut a, 3); + let child = a.active_viewport(); + assert_ne!(child, ViewportId::ROOT, "the new window has a viewport of its own"); + assert_eq!(DRAWN.with(|d| d.borrow().clone()), [child, ViewportId::ROOT], "both windows draw each frame"); + frames(&ctx, &mut a, 3); + assert!(UPLOADS.with(|u| *u.borrow()) > 0, "the pages were rendered"); + UPLOADS.with(|u| *u.borrow_mut() = 0); + frames(&ctx, &mut a, 2); + assert_eq!(UPLOADS.with(|u| *u.borrow()), 0, "a window whose document didn't change renders no page again"); + assert!(a.sent_title.starts_with("Document2"), "{}", a.sent_title); + assert_eq!(a.session.document_id(), second); + a.run("text.insert", json!({"text": "Second"})).unwrap(); + + focus(ViewportId::ROOT); + frames(&ctx, &mut a, 2); + assert_eq!(a.active_viewport(), ViewportId::ROOT); + assert_eq!(a.session.document_id(), first); + assert_eq!((text(&a), a.ui.tab.as_str(), prompt(&a)), ("First".into(), "Insert", Some("paragraph"))); + focus(child); + frames(&ctx, &mut a, 2); + assert_eq!((text(&a), a.ui.tab.as_str(), prompt(&a)), ("Second".into(), crate::DEFAULT_TAB, None)); + } + + fn docx_on_disk(name: &str, body: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("wordcraft-windows-{name}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(format!("{name}.docx")); + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("text.insert", &json!({"text": body})).unwrap(); + s.run("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); + path + } + + /// Open of a file a window already shows brings that window to the front; Open with only an + /// untouched blank document open replaces it in its own window. + #[test] + fn open_focuses_the_files_window_or_reuses_a_blank_one() { + let path = docx_on_disk("report", "Report"); + let ctx = backend(); + let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); + frames(&ctx, &mut a, 2); + let window = a.windows.shown(); + a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!((a.window_count(), a.windows.shown(), text(&a).as_str()), (1, window, "Report"), "the blank document's window shows it"); + let report = a.session.document_id(); + + a.run("file.new", json!({})).unwrap(); + frames(&ctx, &mut a, 2); + assert_eq!(a.window_count(), 2); + assert_ne!(a.session.document_id(), report); + COMMANDS.with(|c| c.borrow_mut().clear()); + let r = a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(r["alreadyOpen"], true); + assert_eq!((a.window_count(), a.windows.shown(), a.session.document_id()), (2, window, report)); + frames(&ctx, &mut a, 1); + assert!(sent(ViewportId::ROOT, &ViewportCommand::Focus), "the file's window comes to the front"); + assert_eq!(a.session.document_id(), report); + std::fs::remove_dir_all(path.parent().unwrap()).ok(); + } + + /// A window's close button closes it, asking first about unsaved changes; after Don't Save the + /// other window comes forward. When the root viewport's window closes, the other window moves + /// into the root viewport. + #[test] + fn closing_a_window_asks_first_and_the_root_adopts_another() { + let ctx = backend(); + let mut a = app("Kept"); + a.run("file.save", json!({"path": docx_on_disk("kept", "Kept").to_string_lossy()})).unwrap(); + let kept = a.session.document_id(); + frames(&ctx, &mut a, 2); + a.run("file.new", json!({})).unwrap(); + a.run("text.insert", json!({"text": "Dropped"})).unwrap(); + frames(&ctx, &mut a, 2); + let child = a.active_viewport(); + + INFOS.with(|i| i.borrow_mut().entry(child).or_default().events.push(ViewportEvent::Close)); + frame(&ctx, &mut a); + assert_eq!(prompt(&a), Some("saveChanges"), "the dirty window asks"); + assert_eq!((a.window_count(), a.active_viewport()), (2, child)); + a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); + assert_eq!((a.window_count(), a.session.document_id(), text(&a).as_str()), (1, kept, "Kept")); + assert_eq!(a.active_viewport(), ViewportId::ROOT); + frames(&ctx, &mut a, 2); + assert_eq!(DRAWN.with(|d| d.borrow().clone()), [ViewportId::ROOT], "the closed window's viewport is gone"); + + a.run("file.new", json!({})).unwrap(); + let other = a.windows.shown(); + frames(&ctx, &mut a, 2); + COMMANDS.with(|c| c.borrow_mut().clear()); + INFOS.with(|i| i.borrow_mut().entry(ViewportId::ROOT).or_default().events.push(ViewportEvent::Close)); + assert!(a.closes_a_window(), "the host leaves the root's close to the app"); + frame(&ctx, &mut a); + assert!(sent(ViewportId::ROOT, &ViewportCommand::CancelClose), "the app keeps running"); + assert!(!a.quit_requested); + assert_eq!((a.window_count(), a.windows.shown()), (1, other)); + assert_eq!(a.active_viewport(), ViewportId::ROOT, "the remaining window moved into the root viewport"); + assert_eq!(a.canvas.scroll_request, Some(a.canvas.scroll_offset), "its page stays where it was"); + frames(&ctx, &mut a, 2); + assert_eq!(DRAWN.with(|d| d.borrow().clone()), [ViewportId::ROOT]); + assert!(a.has_document() && a.session.documents().len() == 1); + } + + /// A request an agent's command makes for the document of another window is that window's: + /// Print opens the Print page there when it draws, and the active window and document stay + /// as they are, so the agent's next command works where it did. + #[test] + fn a_request_for_another_windows_document_is_handled_there() { + let ctx = backend(); + let mut a = app("First"); + let first = a.session.document_id(); + let first_window = a.windows.shown(); + frames(&ctx, &mut a, 2); + a.run("file.new", json!({})).unwrap(); + let second = a.session.document_id(); + frames(&ctx, &mut a, 2); + let child = a.active_viewport(); + a.session.with_document(first, |s| s.run("file.print", &json!({}))).unwrap().unwrap(); + for req in std::mem::take(&mut a.session.ui_requests) { + assert_eq!(req["document"], first); + a.handle_request(&req); + } + assert_eq!((a.session.document_id(), a.active_viewport()), (second, child), "the active document stays"); + assert!(!a.ui.backstage); + frames(&ctx, &mut a, 2); + assert_eq!((a.session.document_id(), a.active_viewport()), (second, child)); + assert!(!a.ui.backstage, "the active window keeps its page"); + let other = a.windows.get(first_window).and_then(|w| w.ui.as_ref()).map(|w| (w.backstage, w.backstage_page.as_str())); + assert_eq!(other, Some((true, "print")), "the Print page opened in the document's window"); + } + + /// Quitting from the Start screen's empty window asks about the documents other windows + /// show (one an agent made and typed in). + #[test] + fn quitting_from_the_empty_window_asks_about_the_other_windows() { + let ctx = backend(); + let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); + frames(&ctx, &mut a, 2); + a.run("file.close", json!({})).unwrap(); + assert!(!a.has_document()); + let empty = a.windows.shown(); + let placeholder = a.session.document_id(); + let other = a.session.with_document(placeholder, |s| s.add_document(wordcraft_doc::Document::new())).unwrap(); + a.session.with_document(other, |s| s.run("text.insert", &json!({"text": "Agent"}))).unwrap().unwrap(); + frames(&ctx, &mut a, 2); + assert_eq!((a.window_count(), a.windows.shown()), (2, empty), "the agent's document has a window behind"); + assert!(!a.has_document()); + + assert!(!a.quit()); + assert_eq!((a.session.document_id(), prompt(&a), text(&a).as_str()), (other, Some("saveChanges"), "Agent")); + assert_ne!(a.windows.shown(), empty, "the document's own window asks"); + a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); + assert!(a.quit_requested); + } + + /// Mod+W in a window with no changes closes it; the keys typed after it in the same frame + /// don't reach the window that comes forward. + #[test] + fn keys_after_closing_a_window_go_nowhere() { + let ctx = backend(); + let mut a = app("First"); + frames(&ctx, &mut a, 2); + a.run("file.new", json!({})).unwrap(); + let child = a.active_viewport(); + focus(child); + frames(&ctx, &mut a, 8); + assert!(a.canvas.focused); + let mac = cfg!(target_os = "macos"); + let modifiers = egui::Modifiers { ctrl: !mac, mac_cmd: mac, command: true, ..Default::default() }; + EVENTS.with(|e| { + e.borrow_mut().entry(child).or_default().extend([ + egui::Event::Key { key: egui::Key::W, physical_key: None, pressed: true, repeat: false, modifiers }, + egui::Event::Text("x".into()), + ]) + }); + frame(&ctx, &mut a); + assert_eq!(a.window_count(), 1, "Mod+W closed the window"); + frames(&ctx, &mut a, 2); + assert_eq!(text(&a), "First"); + } + + /// A window whose document closed without the windows following (an agent closed it) isn't + /// drawn or made active; the next frame closes it. + #[test] + fn a_window_whose_document_closed_is_not_entered() { + let ctx = backend(); + let mut a = app("First"); + let first = a.session.document_id(); + let first_window = a.windows.shown(); + frames(&ctx, &mut a, 2); + a.run("file.new", json!({})).unwrap(); + let (second, second_window) = (a.session.document_id(), a.windows.shown()); + frames(&ctx, &mut a, 2); + a.session.close_document(first).unwrap(); + assert!(!a.enter_window(first_window)); + assert_eq!((a.windows.shown(), a.session.document_id()), (second_window, second)); + frames(&ctx, &mut a, 2); + assert_eq!((a.window_count(), a.session.document_id()), (1, second)); + } + + /// A composition in one window shows and commits only there: the other window draws no + /// composition and its document gets none of the text. + #[test] + fn a_composition_stays_in_its_window() { + let ctx = backend(); + let mut a = app("A"); + frames(&ctx, &mut a, 2); + a.run("file.new", json!({})).unwrap(); + a.run("text.insert", json!({"text": "B"})).unwrap(); + let b = a.active_viewport(); + focus(ViewportId::ROOT); + frames(&ctx, &mut a, 8); + assert_eq!(a.active_viewport(), ViewportId::ROOT); + assert!(a.canvas.focused, "the page has the keyboard"); + EVENTS.with(|e| { + e.borrow_mut() + .entry(ViewportId::ROOT) + .or_default() + .push(egui::Event::Ime(egui::ImeEvent::Preedit { text: "にほん".into(), active_range_chars: None })) + }); + frames(&ctx, &mut a, 2); + assert_eq!(a.canvas.ime_preedit, "にほん"); + let other = a.windows.open.iter().find_map(|w| w.ui.as_ref()); + assert!(other.is_some_and(|w| w.canvas.ime_preedit.is_empty()), "the other window composes nothing"); + assert!(IME.with(|i| i.borrow().get(&ViewportId::ROOT).cloned().flatten()).is_some(), "the composing window places the candidate window"); + + focus(b); + frames(&ctx, &mut a, 2); + assert!(a.canvas.ime_preedit.is_empty(), "the focused window has no composition"); + EVENTS.with(|e| e.borrow_mut().entry(ViewportId::ROOT).or_default().push(egui::Event::Ime(egui::ImeEvent::Commit("にほん".into())))); + frames(&ctx, &mut a, 2); + assert_eq!(text(&a), "B", "a commit in the other window doesn't land here"); + } + + /// Control-channel typing goes to the active window when it has a viewport of its own (the + /// root's raw-input hook doesn't see that window's input), once. + #[test] + fn control_typing_reaches_the_active_window() { + let ctx = backend(); + let mut a = app("First"); + frames(&ctx, &mut a, 2); + a.run("file.new", json!({})).unwrap(); + let child = a.active_viewport(); + focus(child); + frames(&ctx, &mut a, 8); + assert!(a.canvas.focused, "the new window's page has the keyboard"); + let (req, _reply) = crate::ControlRequest::new("ui.text", json!({"text": "Typed"})); + assert!(matches!(crate::control::handle(&mut a, &ctx, &req), crate::control::Outcome::Done(_))); + frames(&ctx, &mut a, 4); + assert_eq!(text(&a), "Typed"); + assert!(a.synthetic.is_empty()); + focus(ViewportId::ROOT); + frames(&ctx, &mut a, 2); + assert_eq!(text(&a), "First", "the other window got none of it"); + } + + /// `ui.screenshot` captures the active window: the capture is sized from its viewport and the + /// command goes to that viewport. A capture still pending when another window comes forward + /// fails with an error instead of capturing that window. + #[test] + fn screenshot_captures_the_active_window() { + let ctx = backend(); + let mut a = app("First"); + frames(&ctx, &mut a, 2); + a.run("file.new", json!({})).unwrap(); + let child = a.active_viewport(); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); + INFOS.with(|i| i.borrow_mut().entry(child).or_default().inner_rect = Some(rect)); + focus(child); + frames(&ctx, &mut a, 4); + + let (reply, replies) = std::sync::mpsc::channel(); + crate::control::admit_screenshot(&mut a, &ctx, None, reply); + assert!(replies.try_recv().is_err(), "the capture is admitted"); + assert_eq!(a.shots.requests.iter().map(|s| (s.viewport, s.expected)).collect::>(), [(child, [800, 600])]); + a.shots.requests.iter_mut().for_each(|s| s.ready_at = 0.0); + COMMANDS.with(|c| c.borrow_mut().clear()); + frames(&ctx, &mut a, 4); + let shot_to = + |id: ViewportId| COMMANDS.with(|c| c.borrow().get(&id).is_some_and(|v| v.iter().any(|c| matches!(c, ViewportCommand::Screenshot(_))))); + assert!(shot_to(child), "the active window is captured"); + assert!(!shot_to(ViewportId::ROOT), "the first window is not"); + + let (reply, later) = std::sync::mpsc::channel(); + crate::control::admit_screenshot(&mut a, &ctx, None, reply); + focus(ViewportId::ROOT); + frames(&ctx, &mut a, 2); + for r in [replies, later] { + let v = r.try_recv().expect("the capture was answered"); + assert_eq!(v["ok"], false); + assert!(v["error"].as_str().is_some_and(|e| e.contains("another window")), "{v}"); + } + assert!(a.shots.requests.is_empty()); + } + + /// Quitting with two documents of unsaved changes asks about each in its window, the window + /// coming to the front; Cancel keeps both. + #[test] + fn quitting_asks_about_each_document_in_its_window() { + let ctx = backend(); + let mut a = app("One"); + let one = a.session.document_id(); + frames(&ctx, &mut a, 2); + a.run("file.new", json!({})).unwrap(); + a.run("text.insert", json!({"text": "Two"})).unwrap(); + let two = a.session.document_id(); + let child = a.active_viewport(); + frames(&ctx, &mut a, 2); + + assert!(!a.quit()); + assert_eq!((a.session.document_id(), prompt(&a)), (two, Some("saveChanges")), "the active window asks first"); + a.run("ui.saveChanges", json!({"answer": "cancel"})).unwrap(); + assert!(!a.quit_requested); + assert_eq!((a.window_count(), a.session.documents().len()), (2, 2), "Cancel keeps both"); + + assert!(!a.quit()); + a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); + assert_eq!((a.session.document_id(), prompt(&a)), (one, Some("saveChanges")), "then the other window asks"); + assert_eq!(a.window_count(), 1); + COMMANDS.with(|c| c.borrow_mut().clear()); + frame(&ctx, &mut a); + assert!(sent(ViewportId::ROOT, &ViewportCommand::Focus), "its window comes to the front"); + assert!(!DRAWN.with(|d| d.borrow().contains(&child))); + a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); + assert!(a.quit_requested); + } + + fn window_labels(a: &WordApp) -> Vec { + let mut l: Vec = a.switch_windows_entries().into_iter().map(|(_, label, _)| label).collect(); + l.sort(); + l + } + + /// View › New Window opens a second window on the document, titled `Name:1` / `Name:2`. Text + /// typed in one shows in the other; each keeps its own caret and zoom. Typing in a window + /// whose caret was in text the other window deleted lands at the end of what is left, and + /// Undo in that window undoes its own typing, then the other window's delete. + #[test] + fn new_window_shows_the_document_twice_each_with_its_own_caret_and_zoom() { + let ctx = backend(); + let mut a = app("Hello world"); + let doc = a.session.document_id(); + frames(&ctx, &mut a, 2); + a.run("view.newWindow", json!({})).unwrap(); + assert_eq!((a.window_count(), a.session.document_id()), (2, doc)); + frames(&ctx, &mut a, 3); + let b = a.active_viewport(); + assert_ne!(b, ViewportId::ROOT); + assert_eq!(window_labels(&a), ["Document1:1 •", "Document1:2 •"]); + assert_eq!(a.window_title(), super::title("Document1:2", true)); + + a.run("view.zoom", json!({"value": 150})).unwrap(); + a.run("text.insert", json!({"text": "!"})).unwrap(); + focus(ViewportId::ROOT); + frames(&ctx, &mut a, 2); + assert_eq!(a.active_viewport(), ViewportId::ROOT); + assert_eq!(text(&a), "Hello world!", "the edit shows in the other window"); + assert_eq!((a.session.view.zoom, a.session.sel.focus.off), (1.0, 11), "window A keeps its zoom and caret"); + + a.run("select.text", json!({"text": " world!"})).unwrap(); + a.run("text.delete", json!({})).unwrap(); + focus(b); + frames(&ctx, &mut a, 2); + assert_eq!(a.active_viewport(), b); + assert!((a.session.view.zoom - 1.5).abs() < 1e-6, "window B keeps its zoom"); + a.run("text.insert", json!({"text": "?"})).unwrap(); + assert_eq!(text(&a), "Hello?", "B's caret was clamped into what is left"); + a.run("edit.undo", json!({})).unwrap(); + assert_eq!(text(&a), "Hello"); + a.run("edit.undo", json!({})).unwrap(); + assert_eq!(text(&a), "Hello world!", "Undo in B undoes A's delete"); + frames(&ctx, &mut a, 2); + assert_eq!(a.session.documents().len(), 1); + } + + /// Closing one of two windows on a document asks nothing, even with unsaved changes, and the + /// other window goes back to the plain name; closing the last one asks. Quitting with a + /// document in two windows asks once. + #[test] + fn closing_one_of_two_windows_on_a_document_asks_nothing() { + let ctx = backend(); + let mut a = app("Kept"); + let doc = a.session.document_id(); + frames(&ctx, &mut a, 2); + a.run("view.newWindow", json!({})).unwrap(); + frames(&ctx, &mut a, 2); + let child = a.active_viewport(); + INFOS.with(|i| i.borrow_mut().entry(child).or_default().events.push(ViewportEvent::Close)); + frame(&ctx, &mut a); + assert_eq!(prompt(&a), None, "no prompt while another window shows the document"); + assert_eq!((a.window_count(), a.session.document_id(), text(&a).as_str()), (1, doc, "Kept")); + assert!(a.session.dirty); + frames(&ctx, &mut a, 2); + assert_eq!(a.window_title(), super::title("Document1", true)); + a.run("file.close", json!({})).unwrap(); + assert_eq!(prompt(&a), Some("saveChanges"), "the last window asks"); + a.run("ui.saveChanges", json!({"answer": "cancel"})).unwrap(); + + a.run("view.newWindow", json!({})).unwrap(); + frames(&ctx, &mut a, 2); + assert_eq!(a.window_count(), 2); + assert!(!a.quit()); + assert_eq!(prompt(&a), Some("saveChanges")); + a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); + assert!(a.quit_requested, "one prompt for the document"); + } + + /// Two windows, the second (focused) on an untouched blank document: an app whose host + /// answers file dialogs only when the test does, plus the dialog's answer channel. + fn blank_beside_a_document() -> (egui::Context, WordApp, Shown, u64) { + let shown: Shown = std::rc::Rc::default(); + let log = shown.clone(); + let services = Services { + file_dialog: Some(Box::new(move |_| { + let (tx, rx) = std::sync::mpsc::channel(); + log.borrow_mut().push(tx); + rx + })), + ..Default::default() + }; + let ctx = backend(); + let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), services); + frames(&ctx, &mut a, 2); + a.run("file.open", json!({"path": docx_on_disk("first", "First").to_string_lossy()})).unwrap(); + a.run("file.new", json!({})).unwrap(); + frames(&ctx, &mut a, 2); + assert!(a.window_count() == 2 && a.session.is_untouched_blank()); + let blank = a.windows.shown(); + (ctx, a, shown, blank) + } + + type Shown = std::rc::Rc>>>; + + /// Each way of opening a file replaces an untouched blank document in its own window, even + /// when the file arrives while another window draws: File › Open, ⌘O and the File page's + /// Browse (an asynchronous dialog), the File page's and Start screen's recent list, and + /// Finder (between frames, in the focused window). + #[test] + fn every_open_replaces_an_untouched_blank_document_in_its_window() { + let second = docx_on_disk("second", "Second"); + let path = second.to_string_lossy().to_string(); + let opens: [(&str, fn(&mut WordApp, &egui::Context, &Shown, &str)); 3] = [ + ("dialog", |a, ctx, shown, path| { + a.run("ui.backstage", json!({"value": true, "page": "open"})).unwrap(); + a.run("ui.openFileDialog", json!({})).unwrap(); + frames(ctx, a, 2); + let tx = shown.borrow_mut().pop().unwrap(); + tx.send(Ok(vec![path.to_string()])).unwrap(); + frames(ctx, a, 3); + }), + ("recent", |a, ctx, _, path| { + crate::backstage::open_recent(a, path).unwrap(); + frames(ctx, a, 2); + }), + ("finder", |a, ctx, _, path| { + a.open_file(path, None).unwrap(); + frames(ctx, a, 2); + }), + ]; + for (how, open) in opens { + let (ctx, mut a, shown, blank) = blank_beside_a_document(); + open(&mut a, &ctx, &shown, &path); + assert_eq!(a.window_count(), 2, "{how}: no third window"); + assert!(a.enter_window(blank), "{how}: the blank document's window is still open"); + assert_eq!(text(&a), "Second", "{how}: it shows the opened file"); + } + std::fs::remove_dir_all(second.parent().unwrap()).ok(); } } diff --git a/crates/ui-egui/src/windows/arrange.rs b/crates/ui-egui/src/windows/arrange.rs new file mode 100644 index 0000000000..76f70a94d7 --- /dev/null +++ b/crates/ui-egui/src/windows/arrange.rs @@ -0,0 +1,293 @@ +//! View › Window across the app's windows (#322): Arrange All, View Side by Side and Synchronous +//! Scrolling. The engine's `view.*` window commands decide and record what to do +//! (`Session::windows`, as the drawn window sees it) and push a request; [`request`] carries it +//! out by placing the windows' viewports, and the pairing lives here. Without native windows (the +//! web app, embedded viewports) the commands are disabled. +//! +//! Synchronous Scrolling hands scrolling from one window to its partner as document points: the +//! canvas adds up what the window scrolled itself (`CanvasState::sync_out`); [`forward_scroll`] +//! files it for the partner, and [`poll`] gives the partner what is waiting for it +//! (`CanvasState::sync_in`) before it draws. + +use std::collections::HashMap; + +use egui::{Rect, Vec2, ViewportCommand, ViewportId, pos2, vec2}; +use serde_json::Value; +use wordcraft_engine::cmd::view::OtherWindow; + +use crate::WordApp; + +/// Positions farther from the origin than this, and larger sizes, are refused. +const MAX_COORD: f32 = 32768.0; +/// Larger scroll steps are refused. +const MAX_SCROLL: f32 = 1.0e6; + +/// View Side by Side and Synchronous Scrolling for the app's windows. +#[derive(Default)] +pub(crate) struct State { + /// The two windows (their keys) viewed side by side. + pair: Option<(u64, u64)>, + /// They scroll together. + sync: bool, + /// Scrolling (document points) the partner did, waiting for the window with this key. + waiting: HashMap, +} + +impl State { + /// The window that `key` is viewed beside. + fn partner(&self, key: u64) -> Option { + match self.pair? { + (a, b) if a == key => Some(b), + (a, b) if b == key => Some(a), + _ => None, + } + } + + fn clear(&mut self) { + self.pair = None; + self.sync = false; + self.waiting.clear(); + } +} + +/// As the drawn window starts its frame: tell the session which other windows there are and +/// whether this one is viewed beside one, and give the window the scrolling its partner did. +pub(crate) fn poll(app: &mut WordApp, _ctx: &egui::Context) { + let key = app.windows.shown; + let open: Vec = app.windows.open.iter().map(|w| w.key).collect(); + let st = &mut app.windows.arranged; + if st.pair.is_some_and(|(a, b)| !open.contains(&a) || !open.contains(&b)) { + // One of the two closed. + st.clear(); + } + let partner = st.partner(key); + let sync = partner.is_some() && st.sync; + let waiting = st.waiting.remove(&key).unwrap_or(0.0); + let native = app.windows.native && app.windows.driven; + let others: Vec = if native { + app.switch_windows_entries().into_iter().filter(|(k, ..)| *k != key).map(|(id, title, _)| OtherWindow { id, title }).collect() + } else { + Vec::new() + }; + let w = &mut app.session.windows; + w.available = native; + w.others = others; + w.side_by_side = partner; + w.sync_scroll = sync; + if sync && waiting.is_finite() { + app.canvas.sync_in += waiting.clamp(-MAX_SCROLL, MAX_SCROLL); + } +} + +/// After the drawn window drew: what it scrolled itself goes to its partner. +pub(crate) fn forward_scroll(app: &mut WordApp, ctx: &egui::Context) { + let key = app.windows.shown; + let dy = std::mem::take(&mut app.canvas.sync_out); + let st = &mut app.windows.arranged; + let Some(partner) = st.partner(key).filter(|_| st.sync && dy.is_finite() && dy != 0.0) else { return }; + let total = st.waiting.entry(partner).or_default(); + *total = (*total + dy).clamp(-MAX_SCROLL, MAX_SCROLL); + ctx.request_repaint_of(app.windows.viewport(partner)); +} + +/// Carry out a window request a `view.*` command made (`{"windows": …}`). +pub(crate) fn request(app: &mut WordApp, req: &Value) { + let Some(what) = req.get("windows").and_then(Value::as_str) else { return }; + let key = app.windows.shown; + let id = |k: &str| req.get(k).and_then(Value::as_u64); + match what { + "sideBySide" => { + if id("off").is_some() { + app.windows.arranged.clear(); + } + if let Some(with) = id("with").filter(|w| *w != key && app.windows.get(*w).is_some()) { + let st = &mut app.windows.arranged; + st.waiting.clear(); + st.pair = Some((key, with)); + st.sync = true; + if let Some(area) = monitor_of(app, key) { + let [left, right] = halves(area); + place(app, key, left); + place(app, with, right); + } + } + } + "syncScroll" => { + if let Some(on) = req.get("value").and_then(Value::as_bool) { + app.windows.arranged.sync = on; + } + } + "arrange" => { + let Some(area) = monitor_of(app, key) else { return }; + let keys: Vec = app.windows.open.iter().map(|w| w.key).collect(); + for (k, r) in keys.into_iter().zip(tile(app.windows.open.len(), area)) { + place(app, k, r); + } + } + _ => {} + } + if let Some(ctx) = app.ctx.as_ref() { + ctx.request_repaint(); + } +} + +/// The monitor window `key` is on. +fn monitor_of(app: &WordApp, key: u64) -> Option { + if !app.windows.native || !app.windows.driven { + return None; + } + let ctx = app.ctx.as_ref()?; + ctx.input_for(app.windows.viewport(key), |i| monitor_area(i.viewport())) +} + +/// Move window `key`'s frame to `r`: un-maximized, its content `r` less the frame's borders, and +/// allowed below the usual minimum size so two halves fit a small screen. Platforms that don't +/// let apps place windows (Wayland) ignore the position. +fn place(app: &WordApp, key: u64, r: Rect) { + let Some(ctx) = app.ctx.as_ref() else { return }; + let viewport: ViewportId = app.windows.viewport(key); + let border = ctx.input_for(viewport, |i| { + let v = i.viewport(); + match (v.outer_rect, v.inner_rect) { + (Some(o), Some(inner)) => (o.size() - inner.size()).max(Vec2::ZERO), + _ => Vec2::ZERO, + } + }); + let inner = (r.size() - border).max(vec2(200.0, 150.0)); + for cmd in [ + ViewportCommand::Fullscreen(false), + ViewportCommand::Minimized(false), + ViewportCommand::Maximized(false), + ViewportCommand::MinInnerSize(inner.min(crate::window_geometry::MIN_SIZE)), + ViewportCommand::InnerSize(inner), + ViewportCommand::OuterPosition(r.min), + ] { + ctx.send_viewport_cmd_to(viewport, cmd); + } +} + +/// The monitor a window is on, in points. egui reports the monitor's size but not where it +/// is, so this assumes monitors of that size laid out in a grid from the primary monitor at the +/// origin, and picks the cell under the window's centre; with monitors of different sizes it can +/// pick the wrong one. +pub(crate) fn monitor_area(v: &egui::ViewportInfo) -> Option { + let size = v.monitor_size.filter(|s| s.is_finite() && s.x >= 1.0 && s.y >= 1.0)?; + let centre = v.outer_rect.or(v.inner_rect).map(|r| r.center()).filter(|c| c.is_finite()).unwrap_or(egui::Pos2::ZERO); + let cell = |c: f32, s: f32| ((c / s).floor() * s).clamp(-MAX_COORD, MAX_COORD); + Some(Rect::from_min_size(pos2(cell(centre.x, size.x), cell(centre.y, size.y)), size)) +} + +/// Arrange All: `n` windows tiled over `area`. One or two windows stack top to bottom, like +/// Word; more fill a grid of ⌈√n⌉ columns, the last row's windows sharing its full width. +pub(crate) fn tile(n: usize, area: Rect) -> Vec { + let n = n.min(64); + if n == 0 { + return Vec::new(); + } + let cols = if n <= 2 { 1 } else { (n as f32).sqrt().ceil() as usize }; + let rows = n.div_ceil(cols); + let h = area.height() / rows as f32; + (0..n) + .map(|i| { + let (row, col) = (i / cols, i % cols); + let in_row = if row + 1 == rows { n - row * cols } else { cols }; + let w = area.width() / in_row as f32; + Rect::from_min_size(pos2(area.min.x + col as f32 * w, area.min.y + row as f32 * h), vec2(w, h)) + }) + .collect() +} + +/// View Side by Side: the left and right halves of `area`. +pub(crate) fn halves(area: Rect) -> [Rect; 2] { + let mid = area.center().x; + [Rect::from_min_max(area.min, pos2(mid, area.max.y)), Rect::from_min_max(pos2(mid, area.min.y), area.max)] +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + use crate::{Services, WordApp}; + + #[test] + fn tiles_cover_the_monitor_without_overlap() { + let area = Rect::from_min_size(pos2(1920.0, 0.0), vec2(1920.0, 1080.0)); + assert!(tile(0, area).is_empty()); + assert_eq!(tile(1, area), vec![area]); + // Two windows stack, like Word's Arrange All. + assert_eq!( + tile(2, area), + vec![Rect::from_min_size(pos2(1920.0, 0.0), vec2(1920.0, 540.0)), Rect::from_min_size(pos2(1920.0, 540.0), vec2(1920.0, 540.0))] + ); + for n in 3..=10 { + let tiles = tile(n, area); + assert_eq!(tiles.len(), n); + let covered: f32 = tiles.iter().map(|r| r.area()).sum(); + assert!((covered - area.area()).abs() < 1.0, "{n} windows cover the monitor"); + for (i, a) in tiles.iter().enumerate() { + assert!(area.expand(0.01).contains_rect(*a), "{n}: {a:?}"); + for b in &tiles[i + 1..] { + assert!(a.intersect(*b).area() < 0.01, "{n}: {a:?} overlaps {b:?}"); + } + } + } + assert_eq!(tile(10_000, area).len(), 64, "hostile counts are capped"); + let [l, r] = halves(area); + assert_eq!( + (l, r), + (Rect::from_min_size(pos2(1920.0, 0.0), vec2(960.0, 1080.0)), Rect::from_min_size(pos2(2880.0, 0.0), vec2(960.0, 1080.0))) + ); + // The monitor is the one under the window's centre. + let info = |x: f32| egui::ViewportInfo { + monitor_size: Some(vec2(1920.0, 1080.0)), + outer_rect: Some(Rect::from_min_size(pos2(x, 100.0), vec2(800.0, 600.0))), + ..Default::default() + }; + assert_eq!(monitor_area(&info(100.0)), Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(1920.0, 1080.0)))); + assert_eq!(monitor_area(&info(2000.0)), Some(area)); + assert_eq!(monitor_area(&egui::ViewportInfo::default()), None); + assert_eq!(monitor_area(&egui::ViewportInfo { monitor_size: Some(vec2(f32::NAN, 1.0)), ..Default::default() }), None); + } + + #[test] + fn side_by_side_scrolls_with_its_partner_only() { + let ctx = egui::Context::default(); + let mut app = WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Services::default()); + app.ctx = Some(ctx.clone()); + app.new_window(); + let keys: Vec = app.windows.open.iter().map(|w| w.key).collect(); + assert_eq!(keys.len(), 2); + let (first, second) = (keys[0], keys[1]); + // Headless: no native viewports, so the commands stay disabled. + poll(&mut app, &ctx); + assert!(!app.session.windows.available); + assert!(app.run("view.sideBySide", json!({})).is_err()); + // Pair the two windows as the request would, then scroll the drawn one. + let shown = app.windows.shown; + let other = if shown == first { second } else { first }; + app.windows.arranged.pair = Some((shown, other)); + app.windows.arranged.sync = true; + poll(&mut app, &ctx); + assert_eq!((app.session.windows.side_by_side, app.session.windows.sync_scroll), (Some(other), true)); + app.canvas.sync_out = 40.0; + forward_scroll(&mut app, &ctx); + assert_eq!(app.canvas.sync_out, 0.0); + assert_eq!(app.windows.arranged.waiting.get(&other), Some(&40.0)); + assert!(!app.windows.arranged.waiting.contains_key(&shown)); + // Nothing is passed on once Synchronous Scrolling is off. + app.windows.arranged.sync = false; + app.canvas.sync_out = 5.0; + forward_scroll(&mut app, &ctx); + assert_eq!(app.windows.arranged.waiting.get(&other), Some(&40.0)); + // Scrolling that is out of range is clamped, not trusted. + app.windows.arranged.sync = true; + app.canvas.sync_out = 1.0e30; + forward_scroll(&mut app, &ctx); + assert_eq!(app.windows.arranged.waiting.get(&other), Some(&MAX_SCROLL)); + // The partner closing ends the pairing. + app.windows.open.retain(|w| w.key == shown); + poll(&mut app, &ctx); + assert_eq!((app.session.windows.side_by_side, app.session.windows.sync_scroll), (None, false)); + } +} diff --git a/crates/ui-egui/src/zotero.rs b/crates/ui-egui/src/zotero.rs index 88e69b1e8a..6d0bd9f7f9 100644 --- a/crates/ui-egui/src/zotero.rs +++ b/crates/ui-egui/src/zotero.rs @@ -46,6 +46,13 @@ pub struct ZoteroLink { methods: Vec, } +impl ZoteroLink { + /// Whether an alert from Zotero is waiting for an answer. + pub(crate) fn alert_open(&self) -> bool { + self.alert.is_some() + } +} + /// Focus requests from the bridge. struct AppHost<'a> { ctx: &'a egui::Context, @@ -156,6 +163,11 @@ pub fn poll(app: &mut WordApp, ctx: &egui::Context) { if app.zotero.busy.is_none() { return; } + // Each window polls as it draws: the window showing the document the command started in + // answers it. + if app.windows.native() && app.session.document_id() != app.zotero.document && app.session.is_open(app.zotero.document) { + return; + } let mut changed = false; // One alert at a time: Zotero sends nothing else while it waits for the answer. while app.zotero.alert.is_none() { @@ -217,6 +229,10 @@ pub fn poll(app: &mut WordApp, ctx: &egui::Context) { /// Zotero's question, as a dialog. pub fn show_alert(app: &mut WordApp, ctx: &egui::Context) { let Some(a) = app.zotero.alert.as_ref() else { return }; + // In the window showing the document Zotero is working on. + if app.windows.native() && app.session.document_id() != app.zotero.document && app.session.is_open(app.zotero.document) { + return; + } // Buttons and the value Zotero expects for each. // macOS puts the default button last (rightmost), with Cancel just before it. let buttons: &[(&str, i64)] = match (a.buttons, cfg!(target_os = "macos")) { @@ -336,6 +352,9 @@ mod tests { json!(["Document_complete", [1]]), ]); let mut app = WordApp::new(Session::new(Document::new()), Services::default()); + // One window showing each document in turn (the web app); with a window per document, + // the first document's window carries on with the citation. + app.windows.single(); app.zotero.options.addr = addr; let ctx = egui::Context::default(); app.ctx = Some(ctx.clone()); diff --git a/crates/zotero/src/bridge.rs b/crates/zotero/src/bridge.rs index b8dfaedac6..50ea5fcd5f 100644 --- a/crates/zotero/src/bridge.rs +++ b/crates/zotero/src/bridge.rs @@ -5,6 +5,8 @@ //! Field ids handed to Zotero stay valid for the transaction: they are kept in a list parallel //! to the document-order list of Zotero fields, updated as fields are inserted and removed. +use std::collections::HashMap; + use serde_json::{Value, json}; use wordcraft_doc::edit::Fragment; use wordcraft_doc::para::OBJ; @@ -192,6 +194,7 @@ impl Bridge { } } + /// Answer one call by its method name; [`Bridge::handle`] wraps it so a panic becomes an error. fn dispatch(&mut self, s: &mut Session, host: &mut dyn Host, c: &Call) -> R { match c.method.as_str() { "Application_getActiveDocument" => { @@ -274,21 +277,8 @@ impl Bridge { // citation moves between the text, footnotes and endnotes (switching styles). "Document_convert" => { let Value::Array(ids) = arg(c, 1)? else { return Err("field ids must be an array".into()) }; - let ids: Vec = ids.iter().take(100_000).cloned().collect(); - let notes = c.args.get(3).cloned().unwrap_or(Value::Null); - for (i, idv) in ids.iter().enumerate() { - let id = match idv { - Value::Number(n) => n.as_u64(), - Value::String(x) => x.trim().parse().ok(), - _ => None, - } - .ok_or("bad field id")?; - let note = match ¬es { - Value::Array(a) => a.get(i).and_then(Value::as_i64).unwrap_or(0), - v => v.as_i64().unwrap_or(0), - }; - self.convert_field(s, id, note)?; - } + let notes = c.args.get(3).unwrap_or(&Value::Null); + self.convert_fields(s, ids, notes)?; Ok(Value::Null) } "Field_convert" => { @@ -484,6 +474,49 @@ impl Bridge { Ok(()) } + /// `Document_convert`: each id once (listed again, its last note type wins), every id + /// checked against one indexed list of the fields before anything moves. Only a citation + /// that actually moves costs a fresh list. + fn convert_fields(&mut self, s: &mut Session, ids: &[Value], notes: &Value) -> Result<(), String> { + let mut todo: Vec<(u64, i64)> = Vec::new(); + let mut slot: HashMap = HashMap::new(); + for (i, idv) in ids.iter().take(fields::MAX_FIELDS).enumerate() { + let id = match idv { + Value::Number(n) => n.as_u64(), + Value::String(x) => x.trim().parse().ok(), + _ => None, + } + .ok_or("bad field id")?; + let note = match notes { + Value::Array(a) => a.get(i).and_then(Value::as_i64).unwrap_or(0), + v => v.as_i64().unwrap_or(0), + }; + match slot.get(&id).and_then(|k| todo.get_mut(*k)) { + Some(t) => t.1 = note, + None => { + slot.insert(id, todo.len()); + todo.push((id, note)); + } + } + } + let index = |ids: &[u64]| -> HashMap { ids.iter().enumerate().map(|(k, id)| (*id, k)).collect() }; + let mut list = self.fields(s); + let mut at = index(&self.ids); + if let Some((id, _)) = todo.iter().find(|(id, _)| !at.contains_key(id)) { + return Err(format!("no field with id {id}")); + } + for (id, note) in todo { + let f = at.get(&id).and_then(|k| list.get(*k)).ok_or_else(|| format!("no field with id {id}"))?; + if !moves(s, f, note) { + continue; + } + self.convert_field(s, id, note)?; + list = self.fields(s); + at = index(&self.ids); + } + Ok(()) + } + fn convert_placeholders(&mut self, s: &mut Session, ph: &[Value], note: i64) -> R { let mut ids = Vec::new(); for v in ph { @@ -519,6 +552,20 @@ impl Bridge { } } +/// Whether moving `f` to the text (`note` 0), a footnote (1) or an endnote (2) changes anything, +/// as [`Bridge::convert_field`] decides it. +fn moves(s: &Session, f: &ZField, note: i64) -> bool { + let current = match f.start.story { + StoryRef::Body => 0, + StoryRef::Part(pid) => match s.doc.parts.get(&pid).map(|p| p.kind) { + Some(PartKind::Footnote) => 1, + Some(PartKind::Endnote) => 2, + _ => return false, + }, + }; + current != if matches!(note, 1 | 2) { note } else { 0 } +} + /// Delete the selection; returns where it collapsed. fn delete_selection(s: &mut Session) -> Result { let (a, b) = s.sel.ordered(); diff --git a/crates/zotero/src/client.rs b/crates/zotero/src/client.rs index bb6fc98b9e..f6e31c83aa 100644 --- a/crates/zotero/src/client.rs +++ b/crates/zotero/src/client.rs @@ -88,7 +88,11 @@ pub struct Outcome { /// Run `command`, answering every call with `answer`. pub fn run_command(opts: &Options, command: Command, answer: &mut dyn FnMut(&Call) -> Result) -> Result { + crate::peer::check_addr(&opts.addr)?; let mut stream = TcpStream::connect_timeout(&opts.addr, opts.connect_timeout).map_err(|e| ZoteroError::NotRunning(e.to_string()))?; + // Before Zotero sees anything: the command, and then the document, go only to this computer + // and, on Linux only, not to a listener the socket table shows belongs to another user. + crate::peer::check(&stream)?; let _ = stream.set_nodelay(true); stream.set_read_timeout(Some(opts.idle_timeout)).map_err(|e| ZoteroError::Io(e.to_string()))?; wire::write_frame(&mut stream, 0, &wire::command_payload(command.wire_name()))?; diff --git a/crates/zotero/src/fields.rs b/crates/zotero/src/fields.rs index c3776f9a51..21f7d2cc07 100644 --- a/crates/zotero/src/fields.rs +++ b/crates/zotero/src/fields.rs @@ -63,7 +63,14 @@ fn story_fields(doc: &Document, story: StoryRef, note_index: u32) -> Vec .collect() } +/// Most fields [`list`] hands Zotero (no real document comes near it). +pub const MAX_FIELDS: usize = 100_000; +/// Most bytes of field codes [`list`] hands Zotero, all fields together. +pub const MAX_CODE_BYTES: usize = 64 * 1024 * 1024; + /// Every Zotero field in document order: the body's, with each note's fields at its reference. +/// A note the body references more than once (a crafted file) is listed at its first reference +/// only. Past [`MAX_FIELDS`] fields or [`MAX_CODE_BYTES`] of codes the rest are left out. pub fn list(doc: &Document) -> Vec { enum Ev { Field(ZField), @@ -71,10 +78,12 @@ pub fn list(doc: &Document) -> Vec { } let mut evs: Vec<(Pos, Ev)> = story_fields(doc, StoryRef::Body, 0).into_iter().map(|f| (f.start.clone(), Ev::Field(f))).collect(); let (mut foot, mut end) = (0u32, 0u32); + let mut seen = std::collections::HashSet::new(); for path in doc.para_paths(StoryRef::Body) { let Some(p) = doc.para(StoryRef::Body, &path) else { continue }; - for off in p.object_offsets() { - if let Some(InlineObject::NoteRef { kind, id, custom }) = p.object_at(off) { + // The k-th object marker holds the k-th object. + for (off, obj) in p.object_offsets().into_iter().zip(&p.objects) { + if let InlineObject::NoteRef { kind, id, custom } = obj { let n = match kind { NoteKind::Footnote => &mut foot, NoteKind::Endnote => &mut end, @@ -82,16 +91,31 @@ pub fn list(doc: &Document) -> Vec { if custom.is_empty() { *n = n.saturating_add(1); } - evs.push((Pos::new(StoryRef::Body, path.clone(), off), Ev::Note(*id, (*n).max(1)))); + if seen.insert(*id) { + evs.push((Pos::new(StoryRef::Body, path.clone(), off), Ev::Note(*id, (*n).max(1)))); + } } } } evs.sort_by(|a, b| a.0.cmp(&b.0)); let mut out = Vec::new(); + let mut bytes = 0usize; + let mut take = |f: ZField, out: &mut Vec| { + bytes = bytes.saturating_add(f.code.len()); + let fits = out.len() < MAX_FIELDS && bytes <= MAX_CODE_BYTES; + if fits { + out.push(f); + } + fits + }; for (_, ev) in evs { - match ev { - Ev::Field(f) => out.push(f), - Ev::Note(id, n) => out.extend(story_fields(doc, StoryRef::Part(id), n)), + let fits = match ev { + Ev::Field(f) => take(f, &mut out), + Ev::Note(id, n) => story_fields(doc, StoryRef::Part(id), n).into_iter().all(|f| take(f, &mut out)), + }; + if !fits { + log::warn!("Zotero: the document has more citation fields than WordCraft hands Zotero; the rest are left out"); + break; } } out diff --git a/crates/zotero/src/lib.rs b/crates/zotero/src/lib.rs index 40eba55ebe..cf4c934431 100644 --- a/crates/zotero/src/lib.rs +++ b/crates/zotero/src/lib.rs @@ -18,6 +18,7 @@ mod bridge; pub mod client; pub mod fields; +mod peer; mod rich; pub mod wire; @@ -37,7 +38,13 @@ pub enum ZoteroError { /// Zotero sent something that isn't the protocol. #[error("unexpected data from Zotero: {0}")] Protocol(String), + /// What answered on Zotero's port may not be Zotero (another computer, on any system; on + /// Linux only, another user's program); it gets no access to the document. + #[error("WordCraft won't let this program edit the document: {0}")] + Refused(String), } #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_limits; diff --git a/crates/zotero/src/peer.rs b/crates/zotero/src/peer.rs new file mode 100644 index 0000000000..09abd8de43 --- /dev/null +++ b/crates/zotero/src/peer.rs @@ -0,0 +1,415 @@ +//! Who answered on Zotero's port. Zotero's protocol has no handshake that proves the other end +//! is Zotero, and whatever answers gets to read and rewrite the open document. Two checks: +//! +//! - **Every system:** WordCraft only connects to this computer (a loopback address). +//! - **Linux only:** the program listening there must not run as another user, from the +//! kernel's socket table (`/proc/net/tcp`): another account on a shared machine that took the +//! port while Zotero wasn't running is refused. A listener the tables don't show (WSL2 +//! mirrored networking with Zotero on Windows, a local proxy) leaves only the loopback rule, +//! but only when the tables were read in full: a read error, or an open error other than +//! "not found" (e.g. a sandbox profile that denies `/proc/net/tcp`), refuses. +//! +//! On Windows, macOS and the BSDs there is **no same-user check**: the loopback rule is the +//! only one, so any local account's listener on the port is taken for Zotero. Those systems have +//! no such table to read without OS calls this crate doesn't make (see `docs/zotero.md`, #3831). + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, TcpStream}; + +use crate::ZoteroError; + +/// Whether `addr` is on this computer; anything else is refused before connecting. +pub(crate) fn check_addr(addr: &SocketAddr) -> Result<(), ZoteroError> { + if addr.ip().to_canonical().is_loopback() { + Ok(()) + } else { + Err(ZoteroError::Refused(format!("{addr} is not on this computer; WordCraft only talks to Zotero on 127.0.0.1"))) + } +} + +/// Refuse the program at the other end of `stream` when it may not be ours (see the module +/// documentation). +pub(crate) fn check(stream: &TcpStream) -> Result<(), ZoteroError> { + let io = |e: std::io::Error| ZoteroError::Io(e.to_string()); + let (local, peer) = (stream.local_addr().map_err(io)?, stream.peer_addr().map_err(io)?); + check_addr(&peer)?; + owner_check(local, peer) +} + +/// Refuse a listener at `peer` that the kernel's socket tables say belongs to another user. +/// A table that can't be read in full (I/O error) is a refusal, never "unknown". +#[cfg(any(target_os = "linux", target_os = "android"))] +fn owner_check(local: SocketAddr, peer: SocketAddr) -> Result<(), ZoteroError> { + let rows = scan_tables(["/proc/net/tcp", "/proc/net/tcp6"], |p| std::fs::File::open(p), local, peer)?; + // Our uid when our own socket's row is missing: `/proc/self` belongs to this process's user. + let me = std::fs::metadata("/proc/self").ok().map(|m| std::os::unix::fs::MetadataExt::uid(&m)); + decide(&rows, peer, me) +} + +/// Windows, macOS, the BSDs: no same-user check (no socket table to read without OS calls this +/// crate doesn't make, #3831); the loopback rule alone decides. +#[cfg(not(any(target_os = "linux", target_os = "android")))] +fn owner_check(_local: SocketAddr, _peer: SocketAddr) -> Result<(), ZoteroError> { + Ok(()) +} + +/// Scan the tables named by `paths` (opened by `open`) in order, stopping once both rows are +/// found. A table that doesn't exist (no IPv6) is skipped; any other open or read error refuses. +#[cfg_attr(not(any(target_os = "linux", target_os = "android")), allow(dead_code))] +fn scan_tables( + paths: [&str; 2], + open: impl Fn(&str) -> std::io::Result, + local: SocketAddr, + peer: SocketAddr, +) -> Result { + let mut rows = Rows::default(); + for path in paths { + match open(path) { + Ok(f) => scan_table(std::io::BufReader::new(f), local, peer, &mut rows)?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(unreadable(&e)), + } + if rows.done() { + break; + } + } + Ok(rows) +} + +/// The uids found for the two rows of one connection. +#[derive(Default)] +struct Rows { + ours: Option, + theirs: Option, +} + +impl Rows { + fn done(&self) -> bool { + self.ours.is_some() && self.theirs.is_some() + } +} + +fn unreadable(e: &std::io::Error) -> ZoteroError { + ZoteroError::Refused(format!("couldn't read the system's socket table ({e}), so the program on the Zotero port can't be checked")) +} + +/// Stream a `/proc/net/tcp`-format table line by line (nothing is kept, so the table's size +/// doesn't matter and nothing is truncated), recording the uids of the connection's two rows. +/// Stops once both are found. Any read error is a refusal: a table we couldn't finish reading +/// must never count as "the listener isn't listed". +fn scan_table(mut table: impl std::io::BufRead, local: SocketAddr, peer: SocketAddr, rows: &mut Rows) -> Result<(), ZoteroError> { + let key = |a: SocketAddr| (a.ip().to_canonical(), a.port()); + let (local, peer) = (key(local), key(peer)); + let mut line = Vec::new(); + loop { + line.clear(); + // Bounded line: a row is ~150 bytes; read at most 4 KiB of one (the rest is skipped). + let n = read_line_capped(&mut table, &mut line).map_err(|e| unreadable(&e))?; + if n == 0 { + return Ok(()); + } + let line = String::from_utf8_lossy(&line); + let mut f = line.split_whitespace(); + let (Some(l), Some(r), Some(uid)) = (f.nth(1).and_then(parse_addr), f.next().and_then(parse_addr), f.nth(4)) else { + continue; + }; + let Ok(uid) = uid.parse::() else { continue }; + let (l, r) = (key(l), key(r)); + if l == local && r == peer { + rows.ours = Some(uid); + } else if l == peer && r == local { + rows.theirs = Some(uid); + } + if rows.done() { + return Ok(()); + } + } +} + +/// Read one line into `out`, keeping at most 4 KiB of it; returns the bytes consumed (0 at the end). +fn read_line_capped(r: &mut impl std::io::BufRead, out: &mut Vec) -> std::io::Result { + const CAP: usize = 4096; + let mut total = 0; + loop { + let buf = match r.fill_buf() { + Ok(b) => b, + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(e) => return Err(e), + }; + if buf.is_empty() { + return Ok(total); + } + let (take, found) = match buf.iter().position(|b| *b == b'\n') { + Some(i) => (i + 1, true), + None => (buf.len(), false), + }; + let keep = take.min(CAP.saturating_sub(out.len())); + out.extend_from_slice(buf.get(..keep).unwrap_or_default()); + r.consume(take); + total += take; + if found { + return Ok(total); + } + } +} + +/// Refuse when the connection's rows show another user. Our uid is our own socket's row, else +/// `me` (this process's). When the listener isn't listed in a table that was read in full (WSL2, +/// a proxy) or our uid is unknown, its owner is unknown and the loopback rule decides. +fn decide(rows: &Rows, peer: SocketAddr, me: Option) -> Result<(), ZoteroError> { + match (rows.ours.or(me), rows.theirs) { + (Some(a), Some(b)) if a == b => Ok(()), + (Some(_), Some(b)) => { + Err(ZoteroError::Refused(format!("the program on port {} runs as another user (uid {b}), so it isn't your Zotero", peer.port()))) + } + _ => { + log::debug!( + "Zotero: couldn't tell which user runs the program on port {} (not in the socket table); trusting the loopback address alone", + peer.port() + ); + Ok(()) + } + } +} + +/// From an in-memory `/proc/net/tcp`-format table (tests and callers with a string): see +/// [`scan_table`] and [`decide`]. +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) fn same_owner(table: &str, local: SocketAddr, peer: SocketAddr, me: Option) -> Result<(), ZoteroError> { + let mut rows = Rows::default(); + scan_table(table.as_bytes(), local, peer, &mut rows)?; + decide(&rows, peer, me) +} + +/// `0100007F:5A5C` (IPv4) or 32 hex digits and a port (IPv6): each 32-bit word as the kernel +/// prints it, in this machine's byte order. +#[cfg_attr(not(any(target_os = "linux", target_os = "android")), allow(dead_code))] +fn parse_addr(s: &str) -> Option { + let (ip, port) = s.split_once(':')?; + let port = u16::from_str_radix(port, 16).ok()?; + let word = |k: usize| ip.get(k * 8..k * 8 + 8).and_then(|w| u32::from_str_radix(w, 16).ok()).map(u32::to_ne_bytes); + let ip = match ip.len() { + 8 => IpAddr::V4(Ipv4Addr::from(word(0)?)), + 32 => { + let mut b = [0u8; 16]; + for (k, w) in b.as_chunks_mut::<4>().0.iter_mut().enumerate() { + *w = word(k)?; + } + IpAddr::V6(Ipv6Addr::from(b)) + } + _ => return None, + }; + Some(SocketAddr::new(ip, port)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `a` the way the kernel's socket table prints it. + fn hex(a: SocketAddr) -> String { + let ip = match a.ip() { + IpAddr::V4(v4) => format!("{:08X}", u32::from_ne_bytes(v4.octets())), + IpAddr::V6(v6) => v6.octets().as_chunks::<4>().0.iter().map(|w| format!("{:08X}", u32::from_ne_bytes(*w))).collect(), + }; + format!("{ip}:{:04X}", a.port()) + } + + /// A socket table row for the connection from `l` to `r`, owned by `uid`. + fn row(l: SocketAddr, r: SocketAddr, uid: u32) -> String { + format!(" 0: {} {} 01 00000000:00000000 00:00000000 00000000 {uid:>4} 0 12345 1 0000000000000000 20 4 30 10 -1\n", hex(l), hex(r)) + } + + const HEADER: &str = " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n"; + + /// Our side and Zotero's listening side of a loopback connection. + fn addrs() -> (SocketAddr, SocketAddr) { + ("127.0.0.1:41234".parse().unwrap(), "127.0.0.1:23116".parse().unwrap()) + } + + #[test] + fn addresses_parse_as_the_kernel_prints_them() { + for a in ["127.0.0.1:23116", "[::1]:23116", "[::ffff:127.0.0.1]:80"] { + let a: SocketAddr = a.parse().unwrap(); + assert_eq!(parse_addr(&hex(a)), Some(a)); + } + for bad in ["", "0100007F", "0100007F:", "XYZ00000:0050", "0100007F:12345", "0100:0050"] { + assert_eq!(parse_addr(bad), None, "{bad}"); + } + } + + #[test] + fn a_listener_of_another_user_is_refused() { + let (local, peer) = addrs(); + let same = format!("{HEADER}{}{}", row(local, peer, 1000), row(peer, local, 1000)); + assert_eq!(same_owner(&same, local, peer, None), Ok(())); + let other = format!("{HEADER}{}{}", row(local, peer, 1000), row(peer, local, 1001)); + assert!(matches!(same_owner(&other, local, peer, None), Err(ZoteroError::Refused(e)) if e.contains("another user"))); + // Junk lines are skipped. + let junk = format!("{HEADER}garbage\n 1: zz:zz yy:yy 01 x x x x\n{}{}", row(local, peer, 7), row(peer, local, 7)); + assert_eq!(same_owner(&junk, local, peer, None), Ok(())); + } + + /// WSL2 mirrored networking (Zotero on Windows) or a local proxy: the listener + /// isn't in our table, so the loopback rule decides, as on other systems. + #[test] + fn an_unlisted_listener_falls_back_to_the_loopback_rule() { + let (local, peer) = addrs(); + let missing = format!("{HEADER}{}", row(local, peer, 1000)); + assert_eq!(same_owner(&missing, local, peer, None), Ok(())); + // An empty table: the listener isn't listed. + assert_eq!(same_owner("", local, peer, None), Ok(())); + assert_eq!(same_owner("", local, peer, Some(1000)), Ok(())); + } + + /// Our own socket's row missing doesn't hide another user's listener: our uid comes from + /// the process itself. + #[test] + fn a_listed_listener_is_checked_against_our_process_uid() { + let (local, peer) = addrs(); + let only_theirs = format!("{HEADER}{}", row(peer, local, 1001)); + assert!(matches!(same_owner(&only_theirs, local, peer, Some(1000)), Err(ZoteroError::Refused(e)) if e.contains("another user"))); + assert_eq!(same_owner(&only_theirs, local, peer, Some(1001)), Ok(())); + // Neither our row nor our process uid: unknown, so the loopback rule decides. + assert_eq!(same_owner(&only_theirs, local, peer, None), Ok(())); + // Our socket's row is ours by definition and wins over the process uid. + let both = format!("{HEADER}{}{}", row(local, peer, 1001), row(peer, local, 1001)); + assert_eq!(same_owner(&both, local, peer, Some(1000)), Ok(())); + } + + #[test] + fn a_dual_stack_listener_is_matched_through_its_mapped_address() { + let (local, peer) = addrs(); + let mapped = |a: SocketAddr| match a.ip() { + IpAddr::V4(v4) => SocketAddr::new(IpAddr::V6(v4.to_ipv6_mapped()), a.port()), + IpAddr::V6(_) => a, + }; + let table = format!("{HEADER}{}{}", row(local, peer, 1000), row(mapped(peer), mapped(local), 1001)); + assert!(matches!(same_owner(&table, local, peer, None), Err(ZoteroError::Refused(e)) if e.contains("another user"))); + } + + /// A reader that yields `data`, then fails. + struct Failing(std::io::Cursor>); + impl std::io::Read for Failing { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + match self.0.read(buf)? { + 0 => Err(std::io::Error::other("injected")), + n => Ok(n), + } + } + } + + /// Repeats `unit` for `left` bytes, then ends: a huge table without holding it in memory. + struct Table { + parts: Vec<(Vec, usize)>, + part: usize, + at: usize, + } + impl std::io::Read for Table { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let Some((unit, times)) = self.parts.get(self.part) else { return Ok(0) }; + if self.at >= unit.len() * times { + self.part += 1; + self.at = 0; + return self.read(buf); + } + let n = buf.len().min(unit.len() - self.at % unit.len()); + let off = self.at % unit.len(); + buf[..n].copy_from_slice(&unit[off..off + n]); + self.at += n; + Ok(n) + } + } + + /// The real attack: a hostile user floods the table with listener rows so the connection's + /// rows sit past any fixed cap. The real read path (`scan_tables`, which `owner_check` + /// calls on `/proc/net/tcp*`) finds them wherever they are. + #[test] + fn rows_far_into_a_huge_table_are_still_found() { + let (local, peer) = addrs(); + let filler = row("127.0.0.1:1".parse().unwrap(), "0.0.0.0:0".parse().unwrap(), 1001); + // More than 64 MiB of rows (the old cap), then the connection's two rows. + let times = 70 * 1024 * 1024 / filler.len(); + assert!(filler.len() * times > 64 * 1024 * 1024); + let tail = format!("{}{}", row(local, peer, 1000), row(peer, local, 1001)).into_bytes(); + let open = |p: &str| match p { + "t4" => { + Ok(Table { parts: vec![(HEADER.as_bytes().to_vec(), 1), (filler.clone().into_bytes(), times), (tail.clone(), 1)], part: 0, at: 0 }) + } + _ => Err(std::io::Error::from(std::io::ErrorKind::NotFound)), + }; + let rows = scan_tables(["t4", "t6"], open, local, peer).unwrap(); + assert_eq!((rows.ours, rows.theirs), (Some(1000), Some(1001))); + assert!(matches!(decide(&rows, peer, None), Err(ZoteroError::Refused(e)) if e.contains("another user"))); + } + + /// A signal interrupting a read is retried, not taken for an unreadable table. + #[test] + fn an_interrupted_read_is_retried() { + struct Eintr(bool, std::io::Cursor>); + impl std::io::Read for Eintr { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + if !std::mem::replace(&mut self.0, true) { + return Err(std::io::ErrorKind::Interrupted.into()); + } + self.1.read(buf) + } + } + let (local, peer) = addrs(); + let data = format!("{HEADER}{}{}", row(local, peer, 7), row(peer, local, 7)).into_bytes(); + let open = |_: &str| Ok(Eintr(false, std::io::Cursor::new(data.clone()))); + let rows = scan_tables(["a", "b"], open, local, peer).unwrap(); + assert_eq!((rows.ours, rows.theirs), (Some(7), Some(7))); + } + + /// A sandbox that denies the table (not "not found") refuses. + #[test] + fn a_table_that_cannot_be_opened_is_refused() { + let (local, peer) = addrs(); + let open = |_: &str| -> std::io::Result<&[u8]> { Err(std::io::ErrorKind::PermissionDenied.into()) }; + assert!(matches!(scan_tables(["a", "b"], open, local, peer), Err(ZoteroError::Refused(_)))); + } + + /// A table that fails mid-read is a refusal, not "listener unknown, trust loopback". + #[test] + fn a_table_that_fails_to_read_is_refused() { + let (local, peer) = addrs(); + let data = format!("{HEADER}{}", row(local, peer, 1000)).into_bytes(); + let mut rows = Rows::default(); + let r = scan_table(std::io::BufReader::new(Failing(std::io::Cursor::new(data))), local, peer, &mut rows); + assert!(matches!(r, Err(ZoteroError::Refused(ref e)) if e.contains("socket table")), "{r:?}"); + } + + #[test] + fn an_overlong_line_is_skipped_without_gluing_to_the_next() { + let (local, peer) = addrs(); + let table = format!("{HEADER}{}\n{}{}", "x".repeat(100_000), row(local, peer, 1000), row(peer, local, 1001)); + assert!(matches!(same_owner(&table, local, peer, None), Err(ZoteroError::Refused(_)))); + } + + #[test] + fn only_this_computer_is_contacted() { + for ok in ["127.0.0.1:23116", "127.9.9.9:1", "[::1]:23116", "[::ffff:127.0.0.1]:23116"] { + assert_eq!(check_addr(&ok.parse().unwrap()), Ok(()), "{ok}"); + } + for bad in ["192.0.2.1:23116", "0.0.0.0:23116", "[2001:db8::1]:23116", "[::]:23116"] { + assert!(matches!(check_addr(&bad.parse().unwrap()), Err(ZoteroError::Refused(_))), "{bad}"); + } + } + + #[test] + fn another_computer_is_refused_before_connecting() { + let opts = crate::client::Options { addr: "192.0.2.1:23116".parse().unwrap(), ..Default::default() }; + let r = crate::client::run_command(&opts, crate::Command::Refresh, &mut |_| Ok(serde_json::Value::Null)); + assert!(matches!(r, Err(ZoteroError::Refused(_))), "{r:?}"); + } + + /// A listener this process opened runs as us: the real socket table agrees. + #[cfg(any(target_os = "linux", target_os = "android"))] + #[test] + fn our_own_listener_passes_the_owner_check() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let stream = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let _accepted = listener.accept().unwrap(); + assert_eq!(check(&stream), Ok(())); + } +} diff --git a/crates/zotero/src/rich.rs b/crates/zotero/src/rich.rs index 2fabb62bb8..705a7e15ec 100644 --- a/crates/zotero/src/rich.rs +++ b/crates/zotero/src/rich.rs @@ -4,10 +4,12 @@ use wordcraft_doc::edit::Fragment; use wordcraft_doc::para::OBJ; -use wordcraft_doc::{Block, CharProps, Document, Paragraph}; +use wordcraft_doc::{Block, CharProps, Document, Paragraph, Run}; /// Most paragraphs taken from one text (a bibliography of thousands of entries is fine). const MAX_PARAS: usize = 100_000; +/// Most formatting runs taken from one text; past it, text keeps the formatting before it. +pub(crate) const MAX_RUNS: usize = 1_000_000; fn style_props(base: &CharProps, src: &CharProps) -> CharProps { let mut p = base.clone(); @@ -38,18 +40,35 @@ fn style_props(base: &CharProps, src: &CharProps) -> CharProps { p } +/// The body paragraphs of `d` (Zotero's RTF or HTML, read), at most [`MAX_PARAS`], formatted on +/// top of `base` with the run styling they carry (past [`MAX_RUNS`] runs, text keeps the +/// formatting before it; each paragraph still needs its first run, so at most [`MAX_PARAS`] more). Objects are dropped. fn from_doc(d: &Document, base: &CharProps) -> Vec { let mut out = Vec::new(); + let mut runs = 0usize; for b in d.body.iter().take(MAX_PARAS) { let Some(src) = b.as_para() else { continue }; let mut p = Paragraph::new(); p.mark = base.clone(); + // Appended run by run, merging only with the run before; normalized once at the end. for (range, props) in src.run_ranges() { let Some(t) = src.text.get(range) else { continue }; let t: String = t.chars().filter(|c| *c != OBJ).collect(); - let at = p.len(); - let _ = p.insert_text(at, &t, &style_props(base, props)); + if t.is_empty() { + continue; + } + let props = style_props(base, props); + p.text.push_str(&t); + match p.runs.last_mut() { + Some(last) if last.props == props || runs >= MAX_RUNS => last.len += t.len(), + _ => { + p.runs.push(Run { len: t.len(), props }); + runs += 1; + } + } } + p.normalize(); + p.touch(); out.push(p); } out diff --git a/crates/zotero/src/tests_limits.rs b/crates/zotero/src/tests_limits.rs new file mode 100644 index 0000000000..ce6b12c673 --- /dev/null +++ b/crates/zotero/src/tests_limits.rs @@ -0,0 +1,122 @@ +//! Hostile documents and calls stay cheap: work grows with the document, not with how often a +//! crafted file or call repeats something (#3692). + +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; +use wordcraft_doc::para::{NoteKind, OBJ}; +use wordcraft_doc::{CharProps, Document, InlineObject, Paragraph, PartKind, Run, StoryRef, para_block}; +use wordcraft_engine::Session; + +use crate::wire::Call; +use crate::{Bridge, Headless, fields}; + +const CITE: &str = r#"ADDIN ZOTERO_ITEM CSL_CITATION {"citationID":"k1","citationItems":[{"id":7}]}"#; + +/// A paragraph of `objects`, one marker each, in one plain run. +fn objects_para(objects: Vec) -> Paragraph { + let mut p = Paragraph::new(); + p.text = std::iter::repeat_n(OBJ, objects.len()).collect(); + p.runs = vec![Run { len: p.text.len(), props: CharProps::default() }]; + p.objects = objects; + p +} + +/// `n` empty Zotero citation fields in a row. +fn citations(n: usize) -> Vec { + (0..n).flat_map(|_| [InlineObject::FieldStart { instr: CITE.into(), locked: false, dirty: false }, InlineObject::FieldEnd]).collect() +} + +// Finding 12: one endnote referenced many times is listed once, not once per reference. +#[test] +fn a_note_referenced_many_times_is_listed_once() { + let mut doc = Document::from_text(""); + let note = doc.add_part(PartKind::Endnote, vec![para_block(objects_para(citations(200)))]); + let refs = (0..5_000).map(|_| InlineObject::NoteRef { kind: NoteKind::Endnote, id: note, custom: String::new() }).collect(); + doc.body = vec![para_block(objects_para(refs))]; + // No clock: listing the note once per reference gave a million fields, which the count catches. + let list = fields::list(&doc); + assert_eq!(list.len(), 200, "each field once, at the note's first reference"); + assert!(list.iter().all(|f| f.start.story == StoryRef::Part(note) && f.note_index == 1)); +} + +#[test] +fn listing_stops_at_the_field_budget() { + let mut doc = Document::from_text(""); + // Many short paragraphs: the per-paragraph object scan is another finding's (#3614). + doc.body = (0..fields::MAX_FIELDS / 100 + 1).map(|_| para_block(objects_para(citations(100)))).collect(); + assert_eq!(fields::list(&doc).len(), fields::MAX_FIELDS); + // Codes count too: a few huge codes fill the byte budget before the field budget. + let big = format!("{CITE}{}", "x".repeat(fields::MAX_CODE_BYTES / 4 - 1000)); + let huge = (0..5).flat_map(|_| [InlineObject::FieldStart { instr: big.clone(), locked: false, dirty: false }, InlineObject::FieldEnd]).collect(); + doc.body = vec![para_block(objects_para(huge))]; + assert_eq!(fields::list(&doc).len(), 4); +} + +// Finding 16: rich text with alternating formatting is built in one pass, not run by run with +// every earlier run merged again. +#[test] +fn rich_text_with_many_runs_is_linear() { + let html: String = "ab".repeat(50_000); + let t = Instant::now(); + let frag = crate::rich::fragment(&html, true, &CharProps::default()); + let elapsed = t.elapsed(); + let Some(wordcraft_doc::Block::Para(p)) = frag.blocks.first() else { panic!("no paragraph") }; + assert_eq!(p.text, "ab".repeat(50_000)); + assert_eq!(p.runs.len(), 100_000); + assert_eq!(p.runs.iter().map(|r| r.len).sum::(), p.text.len()); + // Under half a second on a loaded machine; merging again after every run ran past 9 minutes. + assert!(elapsed < Duration::from_secs(10), "{elapsed:?}"); + // Neighbours with the same formatting are one run. + let frag = crate::rich::fragment("abc", true, &CharProps::default()); + let Some(wordcraft_doc::Block::Para(p)) = frag.blocks.first() else { panic!("no paragraph") }; + assert_eq!((p.text.as_str(), p.runs.len()), ("abc", 2)); + assert_eq!(p.props_of_char(1).italic, Some(true)); +} + +/// Call `method` on a bridge over `s`. +fn call(b: &mut Bridge, s: &mut Session, method: &str, args: Value) -> Result { + let Value::Array(args) = args else { return Err("args must be an array".into()) }; + b.handle(s, &mut Headless::default(), &Call::new(method, args)) +} + +// Finding 32: Document_convert checks each distinct id once against one list of the fields. +#[test] +fn convert_with_one_id_repeated_is_one_check() { + let mut doc = Document::from_text(""); + // Checking each id against a fresh list of the fields took 81 s with 300 of them; with 1,000 + // that is minutes, against milliseconds for one indexed list. + doc.body = (0..1000).map(|_| para_block(objects_para(citations(1)))).collect(); + let mut s = Session::new(doc); + let mut b = Bridge::new(); + call(&mut b, &mut s, "Application_getActiveDocument", json!([3])).unwrap(); + let got = call(&mut b, &mut s, "Document_getFields", json!([1, "Field"])).unwrap(); + let first = got[0][0].clone(); + let ids = vec![first.clone(); 100_000]; + let before = format!("{:?}", s.doc.body); + let t = Instant::now(); + call(&mut b, &mut s, "Document_convert", json!([1, ids, 0, 0])).unwrap(); + assert!(t.elapsed() < Duration::from_secs(10), "{:?}", t.elapsed()); + assert_eq!(format!("{:?}", s.doc.body), before, "already in the text: nothing to do"); + // An unknown id is refused before anything moves. + let r = call(&mut b, &mut s, "Document_convert", json!([1, [first.clone(), 999_999_999u64], 0, [1, 1]])); + assert!(r.is_err()); + assert!(s.doc.parts.is_empty(), "nothing converted"); +} + +#[test] +fn convert_with_a_repeated_id_takes_its_last_note_type() { + let mut doc = Document::from_text(""); + doc.body = (0..3).map(|_| para_block(objects_para(citations(1)))).collect(); + let mut s = Session::new(doc); + let mut b = Bridge::new(); + call(&mut b, &mut s, "Application_getActiveDocument", json!([3])).unwrap(); + let got = call(&mut b, &mut s, "Document_getFields", json!([1, "Field"])).unwrap(); + let (a, c) = (got[0][0].clone(), got[0][2].clone()); + call(&mut b, &mut s, "Document_convert", json!([1, [a.clone(), c, a], 0, [2, 1, 1]])).unwrap(); + let kinds: Vec = s.doc.parts.values().map(|p| p.kind).collect(); + assert_eq!(kinds, vec![PartKind::Footnote, PartKind::Footnote]); + // Ids stay valid: the fields are where Zotero now expects them. + let got = call(&mut b, &mut s, "Document_getFields", json!([1, "Field"])).unwrap(); + assert_eq!(got[2], json!([1, 0, 2])); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 2d38bf65cf..d8ec33e17b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -43,39 +43,32 @@ cat ~/.config/wordcraft/control-key.local-7981 # Linux, outside Flatpak, port `wordcraft-cli mcp --connect` takes the key from `WORDCRAFT_CONTROL_KEY`, or else from the key file for the port it connects to, and reads it again on every new connection (the app may have -restarted). It sends the key only to loopback addresses (`127.0.0.0/8`, `::1`, `localhost`). For +restarted). It sends the key only to the app's own address, `127.0.0.1` (`localhost` connects +there too, never to `::1`): another loopback address such as `::1` or `127.0.0.2` is not where the +app listens, so whoever listens there gets no key (#3699). For any other host it sends no key, says so, and the app refuses its requests: to drive an app on another machine, forward its port to `127.0.0.1` (for example `ssh -L 7981:127.0.0.1:7981 host`) and set `WORDCRAFT_CONTROL_KEY` to that app's key. -## Window channel (View › Window) +## View › Window -Separately from the control channel (which stays off unless `--control` is given), every desktop -window joins a small registry so View › Window works across windows (#322): Switch Windows, -Arrange All, View Side by Side and Synchronous Scrolling. Each window is its own process with one -document. It binds a free loopback port, writes `/windows/.json` (mode 0600 on Unix: -`{"id", "port", "key", "title"}`, a random id and 256-bit key), rewrites it every 30 s and -removes it on exit. Entries whose port refuses connections, or that don't answer and are over two -minutes old, are left from a crash and removed; malformed ones are ignored. - -That port takes one line per connection, `{"key": "…", "from": id, "msg": {"type": …}}`, where -`type` is `ping`, `focus`, `place` (`x`, `y`, `width`, `height` in points), `sideBySide` / -`syncScroll` (`on`) or `scroll` (`dy` in document points), and answers `{"ok": true}` or an -error. Nothing else: it can't run commands or read the document. Lines over 4 KiB, a wrong key, -non-finite or out-of-range numbers and connections silent for 2 s are refused; at most 16 -connections are served at once. The commands are `view.switchWindows {window?}`, -`view.arrangeAll`, `view.sideBySide {value?, window?}` and `view.syncScroll {value?}`; the window -ids and titles are in `ui.inspect` → `windows.others`. Wayland doesn't let apps move windows, so -there Arrange All and Side by Side only resize, and Switch Windows may only flash the window. +The app's native windows (one per document, or several on one document) take part in View › +Window (#322): `view.arrangeAll` tiles them over the monitor, `view.sideBySide {value?, window?}` +places this window and another one in the left and right halves and turns on Synchronous +Scrolling, `view.syncScroll {value?}` turns that on or off, and `view.switchWindows {id?}` lists +and switches documents. `window` is a window id from `ui.inspect` → `windows.others`, the windows +other than the one the command runs in (optional when only one other window is open). The +commands are disabled where there is one window (web, CLI, MCP without the app). Wayland doesn't +let apps move windows, so there Arrange All and Side by Side only resize. ## Methods | Method | Params | Result | |---|---|---| -| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"\|"en"\|"zh-hans"\|"zh-hant"\|"ja"\|"pt-br"\|"es"\|"uk"\|"sr"\|"sr-latn"\|"et"\|"tr"\|"ru"\|"pl"\|"de"\|"it"\|"cs"\|"hu"\|"fr"}`), `ui.theme` (`{"value": "system"\|"light"\|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"\|"on"\|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"\|"picture"\|"recipients"\|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true\|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true\|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true\|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true\|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.focusRibbon` (`{"value": true\|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does)…) | +| `engine.execute` | `command`, `params`, `doc?` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"\|"en"\|"zh-hans"\|"zh-hant"\|"ja"\|"pt-br"\|"es"\|"uk"\|"sr"\|"sr-latn"\|"et"\|"tr"\|"ru"\|"pl"\|"de"\|"it"\|"cs"\|"hu"\|"fr"}`), `ui.theme` (`{"value": "system"\|"light"\|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"\|"on"\|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"\|"picture"\|"recipients"\|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true\|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true\|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true\|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true\|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.focusRibbon` (`{"value": true\|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does), `ui.paneDock` (`{"pane": "navigation"\|"clipboard"\|"styles"\|"styleInspector"\|"accessibility"\|"altText"\|"formatShape"\|"restrictEditing"\|"comments"\|"smartArtText", "dock"?: "left"\|"right"\|"float", "before"?: pane\|null}`; no `dock` reads it; docks or floats a task pane as its menu does, kept between runs; Styles is `view.stylesPaneDock`, #1756; `before` puts the pane directly before that pane in the order, outside it on their edge, as a drop over a docked pane's outer third does, and `null` after every pane; the order is kept between runs)…) | | `engine.commands` | — | every command: id, label, location, shortcut, params, enabled | -| `document.inspect` | `text?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages | -| `ui.inspect` | — | UI state, view, other windows (`windows`), dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf | +| `document.inspect` | `text?`, `doc?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages | +| `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf; `windows`: the other windows as the drawn window sees them (`available`, `others`, `sideBySide`, `syncScroll`); `document`: the active document's id; `documents`: the open documents (`id`, `title`, `path`, `dirty`, `active`) | | `ui.click` | `x`, `y`, `button?`, `count?`, `shift?`, `cmd?`, `alt?` | real pointer input at window coordinates | | `ui.clickText` | `page` (0-based), `x`, `y` (points from the page's top-left), `count?` | click inside a page | | `ui.move` / `ui.drag` | `x`,`y` / `x`,`y`,`toX`,`toY`,`steps?` | pointer move / drag | @@ -84,13 +77,50 @@ there Arrange All and Side by Side only resize, and Switch Windows may only flas | `ui.key` | `key` (`B`, `Enter`, `Left`…), `shift?`, `alt?`, `cmd?` | key press through egui (shortcuts apply) | | `ui.text` | `text` | typed text through egui | | `ui.screenshot` | `path?` | PNG of the window: written to `path`, or without one returned as base64 `png` in the reply | -| `ui.render` | `path?`, `page?` (1-based), `scale?` | PNG of a page, rendered by the engine: written to `path`, or without one returned as base64 `png` in the reply (never a fixed name in the shared temporary folder) | +| `ui.render` | `path?`, `page?` (1-based), `scale?`, `doc?` | PNG of a page, rendered by the engine: written to `path`, or without one returned as base64 `png` in the reply (never a fixed name in the shared temporary folder) | | `ui.parity` | — | feature-catalog parity | | `ui.resize` | `width`, `height` | asks for that window content size in points; sizes that aren't positive and finite are refused, the rest are clamped to the app's minimum (760 × 480) and to the largest surface the GPU can draw (at most 16384; that limit wins over the minimum), and the reply's `width` and `height` are the size asked for | | `ui.focus` / `app.quit` | | window control | Any other method name is treated as a command id: `{"method": "format.bold"}` works. +### Open documents + +Several documents can be open at once. Each has an id, a whole number that stays the same while +the document is open and is never reused by the running app. On the desktop each open document +has a native window of its own; the web app shows them one at a time in its one window. One +document is the active one: on the desktop, the document of the window focused last (the active +window); in the browser, the one the window shows. Commands work on it unless told otherwise. + +- `file.new` opens another document in a new window, focused, and makes it active. `file.open` + does the same with a file; it replaces a blank document nobody has used yet in that document's + window, and a file that is already open becomes active again and its window comes to the front + (`"alreadyOpen": true`). Its result carries the document's `id`. +- `view.switchWindows` lists the open documents (`{"documents": [{"id", "title", "path", + "dirty", "active"}]}`); with `{"id": n}` it makes that document active and brings its window to + the front. +- `file.closeDocument {"id"?, "force"?}` closes a document; its window closes with it unless it + is the last window. +- `engine.execute`, `document.inspect` and `ui.render` take `doc`, the id of the document to work + on, next to their other params. The command runs on that document and the active window stays + active. A dialog or page the command opens (Save As, Print) opens in that document's window, + which comes to the front: + +```json +{"id": 2, "key": "…", "method": "engine.execute", "params": {"command": "text.insert", "params": {"text": "Hello"}, "doc": 3}} +``` + +`doc` left out, or `null`, is the active document. An id that names no open document, or a value +that isn't a whole number from 0 up, is an error and nothing runs. Window commands (`ui.*`) work +on the active window only: with the id of another document they are an error. + +### The active window + +Input and window methods go to the active window, the one focused last: `ui.click`, `ui.key`, +`ui.text`, `ui.wheel` and the other input methods (coordinates are that window's), +`ui.screenshot` (a PNG of that window), `ui.inspect` (its size), `ui.resize` and `ui.focus`. To +work in another window, make its document active first with `view.switchWindows {"id": n}`. + The `CharProps` and `ParaProps` that `format.set`, `para.set` and `styles.modify` take are listed in [MCP server](mcp.md#character-and-paragraph-properties). @@ -114,8 +144,11 @@ document with unsaved changes it replies with an error (the app keeps running an stay); save first (`file.save`), or pass `{"force": true}` to discard the changes and close. The bare form `{"method": "file.close", "force": true}` (a method name that is a command id) is guarded the same way, and only a boolean `true` counts as `force`. A document without unsaved -changes closes at once (#1268). Closing closes the document, not the app: the window stays and -shows no page, and `file.new` or `file.open` goes on from there (#1712). +changes closes at once (#1268). Closing closes the document and its window, and the window +focused before it becomes the active one. The last window stays open with no page, and +`file.new` or `file.open` goes on from there (#1712); closing it doesn't quit the app. With +`doc` naming a document in the background, `file.close` closes that document and its window under +the same rule (unsaved changes need `{"force": true}`), and the active window stays active. ### Native printing diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 41f370a1e4..7f56600bf4 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** trivial (repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** trivial (the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; @@ -49,7 +49,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | `.docx` Word Document (ECMA-376 Transitional) | 70% | R/W | ✅ | ✅ | ~65% | 70–110 | `crates/docx/tests` (roundtrip 34, fixtures 29, malformed 8, bidi 5, bidi_runs 12, citations 5); synthetic fixtures only | Word opens our files. Untested on a real-world corpus. Tracked changes inside a field result stay revisions (the field is kept as a range, #1037). Details below | | `.docx` Strict Open XML | 1% | R/W | ✅ (namespace mapping in `xml.rs`) | ❌ | ~60% | 2–4 | `strict_namespace_and_bad_numbers`, `strict_line_heights_with_a_unit_are_read_as_point_multiples` | Writing Strict is rare; unit-suffixed measures (`13.90pt`) read as points (#2329) | | `.docm` / `.dotx` / `.dotm` | 3% | R/W | ✅ | ✅ | ~65% | incl. above | `macro_packages.rs` | Macros and signatures kept on save (#172); macros never run | -| `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]). Table cell shading from every SHD record (`sprmTDefTableShd`, `sprmTDefTableShdRaw` and their 2nd/3rd ranges, with the Word 97 `Shd80` as fallback; a clear pattern shows its background colour); the row-end mark is not a cell, and each table has its own grid and properties (#1380). Horizontally merged rows stored as fewer, wider cells come with #1123. Freeform shapes, z-order and Word 6/95 files are dropped or refused. Password-protected files (XOR obfuscation, RC4, RC4 CryptoAPI) open after a password prompt, from the public specs; not yet tried on a file Word wrote (AES-encrypted ones are refused). No writer. DIB conversion caps BMP bytes (header included) and encoded PNG at the supplied BLIP byte limit, and uses it as the decoder's allocation limit; a BMP fallback is kept if it fits (related #1334 and #1554). | +| `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]). Table cell shading from every SHD record (`sprmTDefTableShd`, `sprmTDefTableShdRaw` and their 2nd/3rd ranges, with the Word 97 `Shd80` as fallback; a clear pattern shows its background colour); the row-end mark is not a cell, and each table has its own grid and properties (#1380). Horizontally merged rows stored as fewer, wider cells come with #1123. Freeform shapes, z-order and Word 6/95 files are dropped or refused. Password-protected files (XOR obfuscation, RC4, RC4 CryptoAPI) open after a password prompt, from the public specs; not yet tried on a file Word wrote (AES-encrypted ones are refused). No writer. DIB conversion caps BMP bytes (header included) and encoded PNG at the supplied BLIP byte limit, and uses it as the decoder's allocation limit; a BMP fallback is kept if it fits (related #1334 and #1554). Hostile files are bounded: a sector table longer than the file is refused before parsing (#3642); formatting and link runs are read once per change, not per character (#3692, #3708); copies of alt text, link targets and drawn objects share a 64 MB budget (#3709, #3710), and what it leaves out is logged once a kind per document; picture decoding shares a 512 MB budget and aliased store entries decode once (#3711, #3712). | | PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383, also with fonts that draw lam-alef as two glyphs at one x, #2310); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), links, outline, tagging. Outline destinations follow leading page breaks to the heading text. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice | | PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser | | `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). East Asian multibyte code pages decoded as runs: 932 Shift-JIS (`\fcharset128`, Japanese), 936 GBK (134, Simplified Chinese), 949 UHC/windows-949 (129, Korean) and 950 Big5 (136, Traditional Chinese) through `\ansicpg`, `\cpg` or the font's charset, escaped or raw, a lead byte and its trail byte together; invalid sequences read as U+FFFD, and a run that starts `EF BB BF` is text, not a BOM (#1653). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; tracked paragraph marks (`\par` inside `\revised`/`\deleted` groups, including a `\sect` inside a deletion) and character and paragraph formatting revisions (`\crauth`/`\crdate` with `\oldcprops`, `\prauth`/`\prdate` with `\oldpprops`) read and written; section and table formatting revisions (`\oldsprops`, `\oldtprops`) not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export writes every section too (break type, page size and orientation, margins, header/footer distance, columns, page numbering, title page; not headers/footers). Thin: comments, notes, RTL | @@ -92,7 +92,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Fields (`w:fldChar`, `w:fldSimple`), TOC, cross-references, `ADDIN` citations | ✅ | ✅ | Field codes Word supports but we don't evaluate keep their cached result. Textual `REF` fields with `\h` export internal PDF links without refreshing cached text; existing enclosing links take precedence. `wordcraft-cli convert --update-fields` explicitly runs field updates before saving; ordinary conversion preserves caches and locked fields stay unchanged. Instruction-only paragraphs carry hidden marks instead of blank lines; an empty result paragraph keeps its line. Imported TOCs preserve actual result boundaries, formatted entries and nested fields without relying on TOC styles. `EQ \o` (overstrike, enclosed characters) is drawn; enclosed characters are written as Word stores them, their style in the code's run sizes (#297); a cached picture (`INCLUDEPICTURE`) stays as the content of a range field (#465). A `HYPERLINK` field reads as a link, its result ordinary text (tabs, nested `PAGEREF`), and is written back as `w:hyperlink`; its tooltip (`\o`) and target frame (`\t`) are dropped (#2174) | | Move tracking (`w:moveFrom`/`w:moveTo`, `w:moveFromRangeStart/End`, `w:moveToRangeStart/End`), moved paragraph marks | ✅ | ✅ | #400: both ends linked by the range name; ranges rewritten around each moved stretch (ids renumbered); a move missing an end or a range end opens as a plain deletion/insertion. ODT/PDF export the result (moved-away text hidden like deletions); RTF writes a move as a plain tracked deletion and insertion | | Content controls (`w:sdt`) | ✅ | ✅ | Block, inline, row and cell level, nested; title, tag, id, lock, placeholder, temporary, rich/plain text, `w14:checkbox`, combo box, drop-down list, date, picture, building block gallery, `w15` repeating sections; other `w:sdtPr` children (data binding, appearance) kept verbatim (#381). A TOC's control is rebuilt by the TOC when the TOC has the heading-and-entries form; around any other TOC it is kept as read (#2427). Replace All respects content-edit and control-deletion locks and refuses a batch before making any edit | -| Restrict Editing (`w:documentProtection` edit/formatting/enforcement and password hash, `w:locked` styles, `w:permStart`/`w:permEnd` exceptions) | ✅ | ✅ | #412: SHA-512 hash with salt and spin count written; Word's legacy `w:cryptAlgorithmSid`/`w:hash` form (SHA-1/-256/-384/-512, ECMA-376 Part 4 §14.8.1) is kept and verified; MD2/MD4/MD5, `w:algIdExt` and other unsupported forms are refused, never unlocked; `w:colFirst`/`w:colLast` table-column ranges read as plain ranges; forms mode (`w:edit="forms"`) lets edits inside fillable content controls (`w:lock` content-editable, innermost wins) run, everything else rolls back; row-wrapped controls and control deletion remain refused; `review.restrict` reports `formFields`; Find Next Region covers form fields (the pane button shows for forms documents); under active protection, `developer.properties` refuses `lockEdit`/`lockDelete` changes, as Word blocks property edits until Stop Protection; under forms protection it refuses every property, and inserting or removing content controls is refused too (Word greys out the Developer tab's control buttons and Properties and offers no Remove Content Control), while under the other modes the title, tag and placeholder stay editable; a file command that changes the open document is checked like any other, so 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 rather than the snapshot's stale ones; the protection of a protected document only changes through the explicit stop (`review.restrict {"stop": true}` — the "none" mode is the shorthand for it and doesn't stop a protected document), the session author used for new tracked changes and comments can't change through `file.setAuthor` while the document is protected (the CLI's `--author` uses the same protection check), and the post-check compares the document's settings, metadata, custom properties, list definitions, bibliography sources and the comments' resolved flags as well as its text and formatting; named exceptions match a self-declared user name, not an authenticated identity | +| Restrict Editing (`w:documentProtection` edit/formatting/enforcement and password hash, `w:locked` styles, `w:permStart`/`w:permEnd` exceptions) | ✅ | ✅ | #412: SHA-512 hash with salt and spin count written; Word's legacy `w:cryptAlgorithmSid`/`w:hash` form (SHA-1/-256/-384/-512, ECMA-376 Part 4 §14.8.1) is kept and verified; MD2/MD4/MD5, `w:algIdExt` and other unsupported forms are refused, never unlocked; `w:colFirst`/`w:colLast` table-column ranges read as plain ranges; forms mode (`w:edit="forms"`) lets edits inside fillable content controls (`w:lock` content-editable, innermost wins) run, everything else rolls back; row-wrapped controls and control deletion remain refused; `review.restrict` reports `formFields`; Find Next Region covers form fields (the pane button shows for forms documents); under active protection, `developer.properties` refuses `lockEdit`/`lockDelete` changes, as Word blocks property edits until Stop Protection; under forms protection it refuses every property, and inserting or removing content controls is refused too (Word greys out the Developer tab's control buttons and Properties and offers no Remove Content Control), while under the other modes the title, tag and placeholder stay editable; a file command that changes the open document is checked like any other, so 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 rather than the snapshot's stale ones; the protection of a protected document only changes through the explicit stop (`review.restrict {"stop": true}` — the "none" mode is the shorthand for it and doesn't stop a protected document), the session author used for new tracked changes and comments can't change through `file.setAuthor` while the document is protected (the CLI's `--author` uses the same protection check), and the post-check compares the document's settings, metadata, custom properties, list definitions, bibliography sources and the comments' resolved flags as well as its text and formatting; named exceptions match a self-declared user name, not an authenticated identity; formatting-only protection also refuses Copy Styles (`styles.copyFrom`, #3692) | | Legacy form fields (`w:ffData`), check boxes, drop-downs | 🟡 | ✅ | The field definition is kept raw and saved back. Check boxes (`FORMCHECKBOX`) are drawn as Word draws them: a square as tall as the text's line, inset 1.16 pt, 0.72 pt border, an X when checked (`w:checked`, else `w:default`), `w:size` honoured; drop-downs (`FORMDROPDOWN`) show their selected entry; text fields (`FORMTEXT`) show their result. Not yet: filling them in, Protect Document's forms mode for legacy fields | | DrawingML pictures inline and anchored (`wp:anchor`), wrap square/tight/through/top-bottom | ✅ | ✅ | Tight/through read and written with their `wp:wrapPolygon` (and `edited`); without one, a shape's outline is written (a picture's rectangle). `wrapText` (both sides, left, right, largest) read and written for square, tight and through | | Legacy form fields (`w:ffData`), check boxes, drop-downs | ❌ | ❌ | | @@ -111,7 +111,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Native Word ink (`w14:contentPart`, InkML) | 🟡 | 🟡 | Drawing-level ink kept opaque with its InkML part, relationship and content type; direct run-level `w:contentPart` and run-level `mc:AlternateContent` keep the native branch and VML picture fallback. Direct content parts remain direct through repeated save/reopen cycles, without a native rendering. Drawing-level ink shows the preview or a missing-picture box. Ink members of groups (`wpg:wgp`, nested groups too) and drawing canvases (`wpc:wpc`), bare or in `mc:AlternateContent` with a `pic:pic` fallback, keep their markup, part, relationship and place among the members; they are written at the member's current position in the group and drawn from their fallback picture. Such a group can't be ungrouped yet (Ungroup is unavailable, so the ink is never left in a form it can't be saved in). Their part is dropped with the group (no orphan part); ink whose part is missing or isn't InkML gives way to its fallback, else is dropped. No native stroke rendering, eraser, conversion or replay | | 3D models | ❌ | ❌ | Dropped on read, not preserved on save | | Equations (OMML `m:oMath`) | ✅ | ✅ | #191; manual breaks (`m:brk`) and the settings' `m:mathPr` wrapping options (#326). Every ECMA-376 §22.1 element is read and laid out (pinned by `read::math::tests::every_omml_element_keeps_its_content`); equations straight in the body or a cell (no `w:p`), behind `mc:AlternateContent Requires="m"`, or with runs inside hyperlinks, simple fields, content controls and tracked insertions inside the equation keep their content; `w:sym` and Symbol-font text in math become Unicode (#1273). Not yet: Equation Editor 3.0 / MathType OLE objects show their WMF preview only (text in #1994), `m:mathFont` (Cambria Math is always asked for), `m:dispDef`/`m:defJc`/`m:lMargin`, tracked deletions shown struck through inside equations | -| Themes, font table, settings, compatibility mode | ✅ | ✅ | `w:allowSpaceOfSameStyleInTable` read and written in schema order; absent/false default off, enabled flag retained in modes 11/12/14/15. Embedded fonts (`w:embedRegular`, `w:embedBold`…) lay out and export the text set in them for the session (#1176), subsets Word embeds ("embed only the characters used", a made-up family such as `___WRD_EMBED_SUB_46` inside) found by the font table's name (#2240); the font table, its relationships and the embedded font parts are written back unchanged (#770); attached template (`w:attachedTemplate` and its external relationship) and `w:linkStyles` round-trip (#377). `w:removePersonalInformation` and `w:removeDateAndTime` round-trip and are honoured on save (#1062): comment and revision authors written as "Author" (initials "A"), no `dc:creator`/`cp:lastModifiedBy`, no annotation dates; the open document keeps its values (`file.privacyOptions` sets them, `file.privacy` reads them; File › Options › Save › Privacy for this document). The template path comes from the file, so opening never reads it: styles update from it only on an explicit Update Styles. Settings the model doesn't hold (other `w:compatSetting`s, rsids, document variables, proofing state, colour scheme mapping…) survive a save: the writer merges into the file's own `settings.xml` ([below](#settings-preservation)); `w:latentStyles`, `w:default` on localized default styles and lists' `w:nsid`/`w:tmpl`/`w:multiLevelType` round-trip | +| Themes, font table, settings, compatibility mode | ✅ | ✅ | `w:allowSpaceOfSameStyleInTable` read and written in schema order; absent/false default off, enabled flag retained in modes 11/12/14/15. Embedded fonts (`w:embedRegular`, `w:embedBold`…) lay out and export the text set in them for the session (#1176), subsets Word embeds ("embed only the characters used", a made-up family such as `___WRD_EMBED_SUB_46` inside) found by the font table's name (#2240); an embedded font file yields at most 256 faces (collection members and named instances, #3716); the font table, its relationships and the embedded font parts are written back unchanged (#770); attached template (`w:attachedTemplate` and its external relationship) and `w:linkStyles` round-trip (#377). `w:removePersonalInformation` and `w:removeDateAndTime` round-trip and are honoured on save (#1062): comment and revision authors written as "Author" (initials "A"), no `dc:creator`/`cp:lastModifiedBy`, no annotation dates; the open document keeps its values (`file.privacyOptions` sets them, `file.privacy` reads them; File › Options › Save › Privacy for this document). The template path comes from the file, so opening never reads it: styles update from it only on an explicit Update Styles. Settings the model doesn't hold (other `w:compatSetting`s, rsids, document variables, proofing state, colour scheme mapping…) survive a save: the writer merges into the file's own `settings.xml` ([below](#settings-preservation)); `w:latentStyles`, `w:default` on localized default styles and lists' `w:nsid`/`w:tmpl`/`w:multiLevelType` round-trip | | Custom XML parts, document properties, bibliography sources | 🟡 | 🟡 | Custom XML stores reached from the package/main document preserve opaque data, properties and relationships; binding evaluation remains absent. Custom properties and bibliography sources round-trip (#532). Word's own bibliography file (`b:Sources`, `Sources.xml`) imports into Manage Sources, sharing the reader of a document's own bibliography part (#2640) | | Section text direction (`w:textDirection` in `w:sectPr`, §17.6.20) | ✅ | ✅ | `tbRl`, `btLr` (and `tbRlV`/`tbLrV`, read as `tbRl`); `lrTbV` and unknown values read as horizontal (#491) | | Section vertical alignment (`w:vAlign` in `w:sectPr`, §17.6.23) | ✅ | ✅ | `top`, `center`, `bottom` and `both` (Justified, read as Center before #1320); unknown values read as top, top writes nothing (`crates/docx/tests/it/section_valign.rs`). ODT/RTF don't carry it | @@ -121,7 +121,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | `w:fitText` | ❌ | ❌ | East Asian layout | | Glossary document (building blocks) | ✅ | ✅ | `w:docPart` entries (name, style, gallery, category, types, behaviours, description, guid, body) are read into `Document::glossary` and offered by Insert › Quick Parts and AutoText; `save` with `"in": "document"` stores one in the file. An untouched glossary is written back byte for byte with its own parts (styles, settings, fonts…); an edited one, or one whose parts weren't kept, is written from the model as `word/glossary1.xml` with copies of the document's styles and numbering. The glossary's own styles, settings and fonts, text boxes inside entries, and the notes and comments of a selection saved into the document don't survive an edit. Tests: `crates/docx/tests/it/glossary.rs` | | `w:altChunk`, sub-documents | ❌ | ❌ | | -| Hostile input | ✅ | — | `malformed.rs`, capped allocations, never-crash standard | +| Hostile input | ✅ | — | `malformed.rs`, capped allocations, never-crash standard. Failed zip entries count against the size limit, case variants of a part name are one part, embedded fonts and charts are written within budgets, and Flat OPC inlines kept XML only when it is one well-formed element (2026-10-22) | | Document paragraph defaults (`w:pPrDefault`) | ✅ | ✅ | Omission and explicit empty defaults remain distinct across repeated saves; nonempty defaults and later default-spacing edits are written | ## Saving many content controls @@ -268,6 +268,13 @@ Repeated DOCX style IDs use the last complete definition. The original regressio | 2026-10-25 | trivial | Repeated DOCX style IDs: repeated save/reopen regression for whole-definition replacement. | | 2026-10-25 | trivial | Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`) | | 2026-10-23 | trivial | cu20we: preserve and render outline caps across DOCX/VML, screen and PDF; synthetic regressions; readiness estimates unchanged | +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** trivial (the `.doc` reader logs what its copy budget leaves out, once a kind per document; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +| 2026-10-23 | trivial | `.doc` reader logs what its copy budget leaves out (floating objects, link targets, alt text, Prm formatting), once a kind per document (#3692) | +| 2026-10-22 | trivial | `.doc` reader bounds hostile files: oversized sector tables refused, per-character re-reads removed, copy and decode budgets (#3642, #3692, #3708-#3712) | +| 2026-10-22 | trivial | Hostile input: DOCX package, comment, font, chart and Flat OPC hardening (#3692, #3723, #3704, #3742, #3648) | +| 2026-10-22 | trivial | An embedded font file yields at most 256 faces however many collection members or named instances it lists (#3716) | +| 2026-10-22 | trivial | Importer hardening (#3692, #3705, #3713, #3714): spreadsheet header numbering and ODS repeated cells, LaTeX wrapper nesting and verbatim ends, Markdown delimiter runs, blank-span trimming and link destinations, and font/link copies per run are bounded or escaped; normal documents unchanged | +| 2026-10-22 | trivial | Formatting-only protection refuses Copy Styles, as it does Update Styles (#3692) | | 2026-10-23 | trivial | A relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part (#3411 follow-up) | | 2026-10-21 | trivial | The reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, and an id that only appears as text is not a reference (#3411) | | 2026-10-23 | trivial | DOCX: TOC styles Word stores under ids in another language (`Sumrio1` named `toc 1`, `CabealhodoSumrio` named `TOC Heading`) read as `TOC1`…`TOC9` and `TOCHeading` unless the file uses those ids, so Update Table builds the entries in the document's own TOC styles (#3891); a TOC whose heading ends a section (entries in columns after a continuous break) is written and read as the engine's TOC, with the page-number tab at the column's edge (#3889) | diff --git a/docs/gaps.md b/docs/gaps.md index 2b70190a6f..c4ba4fa053 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,5 +1,6 @@ # Where WordCraft falls short of Word +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; 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: 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: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; 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 Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Highlight update:** 2026-10-25 · Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). Readiness estimates unchanged. > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (#7: Custom Document Properties dialog, #2982; previously trivial: egui 0.37 migration: the interface catalogs stay shared and pinned by `craft-i18n.rev` at `9205108cfdced5177706264038848b68b2422d2b` (27 languages, English fallback); input-method handling has new regressions but managed CI passed, and native IME acceptance plus native-speaker catalog review remain open; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: small follow-ups from the field, table and AutoFormat batch, #4232; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; 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 trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; 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: 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: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; 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 Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 @@ -35,6 +36,8 @@ Document renaming: desktop renaming requires a filesystem that supports hard lin Comment command safety (#1690, finding 3): Delete, Resolve, Reply and Edit reject explicit fractional, negative or wrong-type IDs before editing, rather than truncating floats or using the caret. Synthetic tests cover refusal without document or history changes, valid operations with undo/redo and repeated DOCX saves. This narrows the data-path risks in gap #3; the other audit findings and broader stability gate are outside this change. +Proofing robustness (security audit, #3613 part, #3661, #3657, #3625): installed Hunspell dictionaries with thousands of self-continuing rules check each word in linear time and list a bounded number of forms; dictionary files are read with a bounded read (Turkish included) and the hyphenation dictionary inflates under a 64 MiB limit; Russian hyphenation and Turkish suggestions no longer grow with the square of the word's length. This narrows the freeze risks in gap #3; distinct-strip rule chains and proofing off the UI thread remain (#3613). + Font-menu reliability: clearing a query with no matches now shows the recent section from its first row, including its heading, in a scrollable list. Fixed synthetic-family widget and full-ribbon tests cover this case and matching-query controls (#2068). Additive paragraph spacing retains the full before-space after a section page break; ordinary overlapping spacing is unchanged. Other pagination gaps remain open. @@ -43,6 +46,8 @@ Exact line spacing keeps the run font baseline around tall equations; automatic Legacy Word imports (#2445): interactive Save for `.doc`/`.dot` uses the existing DOCX Save As dialog. Cancel and write failures retain the draft and original path; Save before Close continues only after a successful supported-format save. This narrows save reliability under gap #3; binary `.doc` writing remains unsupported. Late Save As answers are rejected before writing if automation has replaced the document. +Document model hangs (#3614, #3692): paragraph object walks, image deduplication, linear-equation letter runs and text box paste no longer do quadratic work or unbounded copies on hostile files. This narrows gap #3; the engine, DOCX reader and UI loops named in #3614 remain. + Active footnote and endnote text stays readable while editing (#2545). This fixes a display-context defect; the broader fidelity, accessibility and readiness gates below are unchanged. Russian language-aware sorting now keeps ё with е instead of after я, with the diaeresis as a secondary difference. Composed and decomposed ё and й compare equally. Fixed synthetic collation tests cover order, case and index headings; this does not establish full Cyrillic or CLDR collation parity. @@ -84,7 +89,7 @@ Text context menus now include the existing formatting mini toolbar (#2959), inc | 23 | **PDF Reflow** (open a PDF as an editable document, Word for Windows) | | | Users who edit PDFs they receive | 30–50 | [file-format-parity.md](file-format-parity.md) | | 24 | **Default shortcuts**: 140 (macOS) / 158 (Linux/Windows) of Word's ~250 (#330, #339, #343; Customize Keyboard #368, Customize Ribbon and Quick Access Toolbar #379; inspector, macros, strikethrough, file/edit keys, the Spike) | | [ui-parity.md](ui-parity.md) | Power users | 1–3 | [ui-parity.md](ui-parity.md) | | 25 | **In-app AI assistant** (Copilot-style draft, rewrite, summarize) | | Chat PR #178 and AI assistant PR #815 open | Users expecting Copilot | 15–30 + owner (provider) | [target-app-parity.md](target-app-parity.md) | -| 27 | **View windows**: Switch Windows, Arrange All, View Side by Side and Synchronous Scrolling landed across WordCraft windows (#322). Left: New Window (a second window on the same document) and Split are placeholders; Wayland doesn't let apps place or raise windows | | [parity-checklist.md](parity-checklist.md) (View 29/29 ids) | Comparing documents | 2–4 | [ui-parity.md](ui-parity.md) | +| 27 | **View windows**: Switch Windows, Arrange All, View Side by Side and Synchronous Scrolling work across the native windows, one per open document or New Window (#322). Left: Split is a placeholder; Wayland doesn't let apps place windows | | [parity-checklist.md](parity-checklist.md) (View 29/29 ids) | Comparing documents | 2–4 | [ui-parity.md](ui-parity.md) | | 26 | **Minor formats**: MHT, Works/WordPerfect | | Word XML Document (Flat OPC) opens and saves, Word 2003 XML opens (#548) and saves (#1081, not yet checked in Word) | Rare | 14–26 | [file-format-parity.md](file-format-parity.md) | | 28 | **Dictate** | | `tools.dictate` missing | Dictation users | 8–15 + owner (speech model) | [hardware-parity.md](hardware-parity.md) | | 29 | **Equations**: ink equations; a copied equation pasting into Word as an editable equation, still to be confirmed (#816, #1297) | | #191 notes; long display equations wrap at operators (#326); a copy holding an equation puts MathML in HTML on the clipboard (#816), and a copy of one equation alone also its MathML in the "MathML" clipboard formats Word reads first (Windows, macOS; #1297; Word/LibreOffice paste not yet verified) | Maths-heavy documents | 2–3 | [layout-parity.md](layout-parity.md) | @@ -145,6 +150,7 @@ Small follow-ups from the 2026-10-25 field, table and AutoFormat batch (#4232): | Date | Change | Summary | |---|---|---| +| 2026-10-22 | trivial | View windows: several open documents and Switch Windows in the engine | | 2026-10-25 | trivial | egui 0.37 migration: interface catalogs remain pinned by `craft-i18n.rev` (`9205108…`; 27 languages, English fallback); IME surrounding deletions and composition resets are implemented with regressions, but managed CI passed; native IME acceptance and native-speaker catalog review remain open; no gap or readiness change | | 2026-10-17 | trivial | Gap 9 narrowed: Standard (Word 2007, AES-ECB) encrypted packages open with a password under any Word extension; AES headers in Word 97-2003 files are outside the public spec and refused with a clear message; estimate 3–5 → 0 h, since the remaining AES-in-`.doc` case is refused by design | | 2026-10-25 | trivial | Small follow-ups from the field, table and AutoFormat batch (#4232) | @@ -158,6 +164,8 @@ Small follow-ups from the 2026-10-25 field, table and AutoFormat batch (#4232): | 2026-10-23 | trivial | cu20we: paragraph-relative box floats use the pre-spacing origin; header/footer/cell/text-box regressions; readiness estimates unchanged | | 2026-10-24 | trivial | Exact-spacing table contact uses the specified line height in row selection and word skipping; large/small mark regressions in modes 11/12/14/15 (#3977); shape estimates unchanged | | 2026-10-24 | trivial | Floating tables count exact line-bottom contact; shapes keep strict overlap (#3977); original boundary/skip regressions in modes 11/12/14/15, expected Word rule from cyril265; readiness estimates unchanged | +| 2026-10-22 | trivial | Gap 3: proofing robustness — linear affix rule walks, bounded dictionary reads, linear Russian hyphenation, Turkish word cap (#3613 part, #3661, #3657, #3625); readiness estimates unchanged | +| 2026-10-22 | trivial | Gap #3 note: document model quadratic scans and text box paste copies bounded (#3692, part of #3614) | | 2026-10-22 | trivial | Gap 10: group connector rerouting preserves rotated/flipped free and unresolved endpoints under nonuniform scaling, with safe degenerate-dimension and display-size-cap handling; routing around shapes and readiness estimates unchanged | | 2026-10-21 | trivial | The ranked gap list held two rows each for #19, #20 and #21; the stale copies are gone, so each gap appears once, and the #20 row carries the measured figure (#3411) | | 2026-10-21 | trivial | #20 narrowed: a document with many relationships opens in the time its part takes (#3411) | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 0251aba93c..36f6989c7c 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -23,7 +23,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Feature | State | Evidence / gap | Hours | |---|---|---|---| | 2026-10-16 | trivial | Split-cell content retains its resolved top margin and border band on continuation pages, matching original Word Mac16.113.2 controls. Synthetic regressions cover margins, borders, line spacing and image frames. | -| Hint-reliant CJK glyph outlines (DFKai-SB / 標楷體) | 🟡 | Required TrueType interpreter runs at units-per-em size; local Windows glyph-bounds regression and synthetic DOCX page/window comparison pass. Other platforms and hint-reliant families remain unmeasured; no native Word comparison. | — | +| Hint-reliant CJK glyph outlines (DFKai-SB / 標楷體) | 🟡 | Required TrueType interpreter runs at units-per-em size; local Windows glyph-bounds regression and synthetic DOCX page/window comparison pass. Other platforms and hint-reliant families remain unmeasured; no native Word comparison. A font asking for a twilight zone over 1,024 points or a stack over 4,096 values, or a glyph past 256 KiB of hinting buffer, is drawn unhinted; on desktop, once at least three of a face's hinted draws have taken over 50 ms and those slow draws are at least 1/16 of its hinted draws, its later glyphs are drawn unhinted (#3692). | — | | Subpoint table grid columns | 🟡 | Preserve positive grid widths rather than imposing a text-box minimum; ECMA-376 §17.4.16. Native Word for Mac 16.113.2 build26092012 confirms narrow trailing grid units. `layout::tests::subpoint_grid_keeps_widths`, `tiny_grid_scales_to_width`. Tiny totals normalize without overflowing the scale factor. Zero and invalid widths retain their fallback. Border painting remains separate. | n/a | | Line breaking (UAX #14 with Word's exceptions: no break after `/`, a break after `-` before a digit) | ✅ | `layout::tests::no_line_break_right_after_a_slash`, `layout::tests::a_hyphen_before_digits_is_a_break_opportunity_like_word`, `layout::tests::a_bookmark_is_no_line_break_opportunity` | — | | A break before a slash that starts a word (" /word" wraps whole; not after a no-break space) | 🟡 | `layout::tests::a_word_after_a_slash_wraps_whole`, `layout::tests::a_no_break_space_before_a_slash_still_holds`; behaviour from the #2963 report, not compared with Word yet | — | @@ -35,7 +35,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Missing Thai document fonts (TH Sarabun New, Angsana New, Cordia New, Browallia New, the UPC families, Leelawadee): one Thai substitute face for the whole run, Latin letters, digits and ฿ included, so the run keeps one font at one size as Word's Cordia New substitution does | ✅ | `fonts::word::tests::a_missing_thai_font_resolves_to_an_installed_thai_face`, `fonts::fontdb::tests::a_thai_run_with_latin_falls_back_to_one_face`, `layout::tests_complex::a_missing_thai_font_draws_the_whole_run_in_one_thai_face`, `layout::tests_complex::a_thai_substitute_without_latin_hands_the_run_to_one_face_covering_it`, `fonts::fontdb::tests_sysfonts::a_run_no_preferred_face_covers_whole_loads_an_installed_font_that_does` (#1549). Windows: Cordia New, then Leelawadee UI, Tahoma, Noto Sans Thai; macOS: Thonburi (Sathu for the serif families); Linux: Noto Sans Thai, then the TLWG fonts. The first listed face with Latin letters and digits as well as Thai wins. Other Thai names (`TH …`, `…UPC`, names with `Thai`) use the same lists. When no listed face is installed, or the one found lacks the run's Thai or Latin letters, the run moves to one installed face covering its Thai and Latin text if there is one, even when a preferred Thai family without Latin letters (Noto Sans Thai) is installed; that face's whole family is loaded, so regular text keeps the Regular face. Only Thai fonts: a missing Latin font keeps Latin text in its own substitute, and a run with its own complex-script font keeps it for the Thai letters, as Word does | — | | Distributed / Thai distributed alignment | 🟡 | read; layout untested | 1–2 | | Default font metrics (Aptos, Calibri, Cambria…) | 🟡 | Missing Arial Unicode MS tries Arial then the Arial substitutes, case-insensitively (`fonts::word::tests::unicode_arial_is_proportional`); glyph coverage and exact Word line height can differ. Carlito/Caladea/Liberation are metric-compatible for Calibri/Cambria/Arial/Times/Courier; **Aptos (Word's default since 2024) has no metric-compatible open substitute**, so new Word documents wrap differently ([typing-parity.md](typing-parity.md) Known gaps). Needs an owner decision (commission or license a metric-matched font) | 6–10 + owner | -| Colour emoji and colour fonts on screen and in page renders: COLR v0/v1 paint graphs (solid fills, linear, radial and sweep gradients, composite modes) and bitmap strikes (Apple Color Emoji `sbix`, Noto Color Emoji `CBDT`) drawn in the font's colours at the layout's position and size; a dark page doesn't invert them, the same as pictures | 🟡 | `fonts::color` (skrifa), `render::color_glyph`; `render::tests_color_glyphs::emoji_are_drawn_in_colour` and `emoji_keep_their_colours_on_a_dark_page` (system font, skip without one), `render::color_glyph::tests`, `fonts::color::tests` (#1269). CBDT is verified on a real page with Noto Color Emoji; COLR v0/v1 and sbix are implemented to the OpenType spec and tested with synthetic data, but not yet seen on a real font (Segoe UI Emoji, COLRv1 Noto, Apple Color Emoji). PDF export already drew them through krilla | 1–2 | +| Colour emoji and colour fonts on screen and in page renders: COLR v0/v1 paint graphs (solid fills, linear, radial and sweep gradients, composite modes) and bitmap strikes (Apple Color Emoji `sbix`, Noto Color Emoji `CBDT`) drawn in the font's colours at the layout's position and size; a dark page doesn't invert them, the same as pictures | 🟡 | `fonts::color` (skrifa), `render::color_glyph`; `render::tests_color_glyphs::emoji_are_drawn_in_colour` and `emoji_keep_their_colours_on_a_dark_page` (system font, skip without one), `render::color_glyph::tests`, `fonts::color::tests` (#1269). A glyph copies at most 65,536 gradient stops and clip-path elements in all, else it falls back to its outline, and the cache charges what it keeps (#3692). CBDT is verified on a real page with Noto Color Emoji; COLR v0/v1 and sbix are implemented to the OpenType spec and tested with synthetic data, but not yet seen on a real font (Segoe UI Emoji, COLRv1 Noto, Apple Color Emoji). PDF export already drew them through krilla | 1–2 | | Full font names in document font requests | ✅ | Exact English (or first available) OpenType name ID4 lookup in loaded faces and the native lazy catalog; family and legacy-family matches keep priority. No style-suffix guessing; named variable-instance aliases remain unsupported | 0 | | Symbol fonts (Symbol including its PCL6 alias, Wingdings, Wingdings 2/3, Webdings) without the proprietary font: each character, stored as U+F020–U+F0FF or a plain 8-bit code, is drawn as its Unicode equivalent from a face that has it; PDF text and copies read as those symbols | ✅ | `doc::symbol_fonts` tables (Adobe Symbol table and Unicode L2/12-368), `layout::tests::wingdings_text_draws_and_reads_as_its_unicode_symbols` (#776). Shapes come from Noto Sans Symbols / Symbols 2, DejaVu Sans or Segoe UI Symbol, so they differ from Word's; Wingdings 0xFF (not in Unicode) still has no symbol. A line keeps the symbol font's own height (Symbol, Wingdings: the ascent and descent Word's PDFs give them), so a Symbol bullet makes its line as tall as in Word, `layout::tests::a_symbol_bullet_sizes_its_line_by_the_symbol_font`. Apple's one-em Symbol takes the same height, as in Word for Mac (`fonts::word::tests::a_one_em_symbol_face_takes_windows_symbols_line`) | 0 | | List bullets stored as a private-use character (U+E000–U+F8FF) in a font that isn't installed and isn't a mapped symbol-font code: drawn as the level's default bullet (• ○ ▪), not as whatever an installed icon font has at that code | ✅ | `layout::tests::private_use_bullet_no_font_draws_shows_the_levels_default_bullet` (#1683: U+F081 showed Font Awesome's Twitter logo) | — | @@ -59,7 +59,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Body top below a tall header, footer pushes body up | ✅ | #138; a footer taller than the bottom margin moves the body bottom up (#453), and a header or footer linked to the previous section takes its room too (#454) | — | | Tables: row border exceptions (`w:tblPrEx/w:tblBorders`) | ✅ | `row_borders_stay_on_row`, `row_nil_overrides_style`: explicit cell borders win; row exceptions precede table styles and table borders | — | | Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues); on a page with footnotes a paragraph ending there keeps its space after above the separator, or its last lines go to the next page with the note (#1116); a long endnote continues on the next column or page at a line boundary (#456), and the caret finds note text on its continuation page (#457); each section's note options: footnotes below the text, endnotes at the end of each section, start at, numbering restarted each section or each page (counted from where the notes land, at most three passes) (#385) | 1–2 | -| Tables: row heights (at least/exact), rows split across pages, header rows repeat, Can't Split | ✅ | 2026-10-06, #138; omitted cell-margin edges inherit current table defaults; every cell in a row gets the row's largest top and bottom margin; an exact height covers the top border, top margin and text, the bottom margin comes on top, and an exact row never splits; a row split across pages keeps its cells' bottom margin, border and last space after on the first page, widow/orphan control applies in its cells, each cell continues at the top of the next page and an at-least row keeps its minimum; an at-least row with less room than its minimum on a page that already holds something starts the next page whole (Allow row to break on or off, vAlign centre too), as observed in Word for Mac 2026-10-15 (a row of about 2.6 cm), #1742; that repeated header rows go with it and that a row taller than a page splits from the top of the next page come from the issue and synthetic tests, not a Word check (`…_an_at_least_row_with_no_room_starts_the_next_page_whole_either_way_breaking_is_set`, `…_an_at_least_row_taller_than_a_page_splits_from_the_top_of_the_next_page`, `layout::tests::a_row_shares_its_largest_top_and_bottom_margins`, `…_a_split_row_keeps_its_bottom_margin_and_widow_control`, `…_split_row_cells_each_continue_at_the_top`) | — | +| Tables: row heights (at least/exact), rows split across pages, header rows repeat, Can't Split | ✅ | 2026-10-06, #138; omitted cell-margin edges inherit current table defaults; every cell in a row gets the row's largest top and bottom margin; an exact height covers the top border, top margin and text, the bottom margin comes on top, and an exact row never splits; a row split across pages keeps its cells' bottom margin, border and last space after on the first page, widow/orphan control applies in its cells, each cell continues at the top of the next page and an at-least row keeps its minimum; an at-least row with less room than its minimum on a page that already holds something starts the next page whole (Allow row to break on or off, vAlign centre too), as observed in Word for Mac 2026-10-15 (a row of about 2.6 cm), #1742; that repeated header rows go with it and that a row taller than a page splits from the top of the next page come from the issue and synthetic tests, not a Word check (`…_an_at_least_row_with_no_room_starts_the_next_page_whole_either_way_breaking_is_set`, `…_an_at_least_row_taller_than_a_page_splits_from_the_top_of_the_next_page`, `layout::tests::a_row_shares_its_largest_top_and_bottom_margins`, `…_a_split_row_keeps_its_bottom_margin_and_widow_control`, `…_split_row_cells_each_continue_at_the_top`); a row's `gridAfter` is bounded to 63 columns in native documents too, and repeated header rows stop after 500 000 copied items across a table (hostile documents, #3692) | — | | Tables: autofit to contents/window, fixed widths, Word 2013 edge | 🟡 | #137; #44: AutoFit Contents measures each column's narrowest and widest text and shares the width like Word, once (not live as you type); consistent a percentage width past 100% is kept, as Word does; a table with its own preferred width keeps it, however far past the margin, and only an automatic-width table over 1.5× the text width is squeezed to the margins (Word squeezes it as soon as it is wider) (#1721); cell widths override stale grid proportions when their totals agree (`cell_widths_override_grid`); conflicting widths and spans still differ | 3–5 | | Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover visible text before it and does not fit below it moves to the next page (#534); empty line-break lines do not count as text; a click below one, outside its cells, goes to the text beside or after it, not to its last row, as checked in Word (#2038); exact line-bottom contact counts for tables only (#3977), both in row-span selection and word skipping; exact-spacing table contact uses the specified height rather than paragraph-mark metrics, with original Source Sans 3 controls in modes 11/12/14/15; expected Word rule from [cyril265's Word for Mac 16.114.1 measurements](https://github.com/storytold/wordcraft/issues/3977#issuecomment-6374217574), not a new Word run of these originals; anchor-order replay remains #3790's scope; other overlap rules untested | 2–3 | | Nested tables | 🟡 | render; polish missing | 2–4 | @@ -67,8 +67,8 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Shape fills: gradients and transparency | 🟡 | #1500: linear (angle, `scaled`) and circular gradients and transparent fills drawn on screen, PNG and PDF (axial/radial shadings), soft edges fading the real fill; rectangular and shape-following path gradients drawn circular, `tileRect`/`flip` not drawn, outline gradients drawn in their middle colour, `rotWithShape="0"` drawn as turning with the shape | 2–4 | | Text wrap: square, top-and-bottom, behind, in front | ✅ | 2026-10-06; beside an object, a line's first word that doesn't fit the strip goes on whole to the row's next strip or the next row, instead of being split (#1376, `layout::tests::a_word_too_wide_for_the_strip_beside_a_float_moves_on_whole`; inferred from a reporter's comparison, not yet measured in Word) a paragraph running on into the next column or page is broken again there around that column's shapes, its own floating objects left where they are (#1374). Text in an earlier column doesn't yet wrap around a shape anchored in a later one (#1158) | #884: an object dragged on a multi-column page anchors to a paragraph in the column it lands in, so that column's text wraps around it. Text laid out before the anchor (an earlier column) doesn't yet wrap around an object anchored later on the page, nor do lines a paragraph continues into the next column (broken once, in the column it starts in) | | Text wrap: square, top-and-bottom, behind, in front | ✅ | 2026-10-06; beside an object, a line's first word that doesn't fit the strip goes on whole to the row's next strip or the next row, instead of being split (#1376, `layout::tests::a_word_too_wide_for_the_strip_beside_a_float_moves_on_whole`; inferred from a reporter's comparison, not yet measured in Word) a paragraph running on into the next column or page is broken again there around that column's shapes, its own floating objects left where they are (#1374). Text in an earlier column doesn't yet wrap around a shape anchored in a later one (#1158) | #884: an object dragged on a multi-column page anchors to a paragraph in the column it lands in, so that column's text wraps around it. Paragraphs above an object's anchor wrap around it when it is placed higher up the page (the page is laid out again with it known from the start, #2048; when that wrap pushes the anchor onto the next page, the first page keeps the room and the object goes with its anchor, and a layout that still hasn't settled moves the object down to its paragraph rather than over text); text in an earlier column beside it doesn't yet, nor do lines a paragraph continues into the next column (broken once, in the column it starts in) | -| Text wrap: tight, through (contour, wrap polygon) | 🟡 | #354: lines follow the wrap polygon, or the outline worked out from the object (shape geometry; a picture's opaque pixels as a convex hull; otherwise the rectangle), turned and flipped with it, kept the left/right distance from text; Through also uses gaps inside the outline. Wrap side (`wrapText`: both sides, left only, right only, largest side per line) applies to square, tight and through wraps, turned objects included; picture outlines are convex; not compared page-for-page with Word | 1–2 | -| Text boxes, overflow, linked text boxes | ✅ | #46; linked text boxes (#369): the text runs on through each box in turn at that box's line length, overflow past the last box hidden; Align Text top / middle / bottom; the box's own internal margins, and a box set to Resize shape to fit text shows all its text (#743) | — | +| Text wrap: tight, through (contour, wrap polygon) | 🟡 | #354: lines follow the wrap polygon, or the outline worked out from the object (shape geometry; a picture's opaque pixels as a convex hull; otherwise the rectangle), turned and flipped with it, kept the left/right distance from text; Through also uses gaps inside the outline. Wrap side (`wrapText`: both sides, left only, right only, largest side per line) applies to square, tight and through wraps, turned objects included; picture outlines are convex; a picture is decoded once for all its crops, pictures whose header says they are opaque are not decoded, and at most 2^28 pixels per layout are decoded (later pictures wrap as rectangles in that layout, #3692); not compared page-for-page with Word | 1–2 | +| Text boxes, overflow, linked text boxes | ✅ | #46; linked text boxes (#369): the text runs on through each box in turn at that box's line length, overflow past the last box hidden; Align Text top / middle / bottom; the box's own internal margins, and a box set to Resize shape to fit text shows all its text (#743); a chain's story is laid out at no more than 16 box widths (told apart to a twip), later boxes needing another taking the nearest one in their text direction (#3692) | — | | Rotated objects and text | 🟡 | #332: pictures, shapes, charts and groups drawn turned and flipped about their centre (screen and PDF); square wrap and inline lines keep clear of the rotated bounds; hit testing on the turned shape. Text in a rotated text box stays upright | 2–3 | | Group connector rerouting | 🟡 | Glued ends keep their existing connection-site path; free ends, missing targets and invalid sites retain their displayed rotated and flipped position when the connector frame is rebuilt, including under nonuniform group scaling. Model and engine regressions cover all connector kinds, both ends, rotations/flips, unit/uniform/nonuniform scale, idempotent rerouting, degenerate dimensions without nonfinite writes, and fallback spans beyond the 4000 pt display-size cap without endpoint movement or repeated shrinkage. Non-invertible display scales and unrepresentable fallback spans leave connectors needing fallback endpoints unchanged; fully resolved connectors still reroute. Invalid child extents use the renderer's unit-scale fallback. Routing around obstacles remains missing; no new native Word comparison | — | | Page borders (from page edge or text), page colour, watermark | ✅ | | — | @@ -196,6 +196,7 @@ No overall parity estimate changes from these probes. | Scrolling a 1,096-page document that is one 5,300-row table (#811) | ~2.4 ms idle, ~4.5 ms median scroll frame (was ~600 ms); opening still lays out for ~4–8 s | not measured | measured 2026-10-12, release, headless (`tests_perf::big_table_scroll_bench`) | | A word with no break opportunity in it, laid out beside a floating object (#3412) | 1 MiB: 29.4 s → 0.35 s; 4 MiB: 472 s → 1.4 s | not measured | measured 2026-10-21, on 9b4b948c before the fix and with it (`layout::tests::a_long_unbreakable_word_lays_out_in_linear_time`, `layout::tests::a_megabyte_long_word_lays_out_quickly`) | | The same word, with a font that pulls half its glyphs back so that its advances alternate in sign (#3412) | 8× the word costs 52× the time before the fix, 15× with it | not measured | measured 2026-10-22, release, on `00bc6a4b` before the fix and with it (`layout::tests::a_word_with_alternating_advances_lays_out_in_linear_time`) | +| Pages, sections and paragraphs with thousands of clipped cells, tracked authors, comment authors, sections, header paragraphs, spanning cells, hidden bidi offsets or math characters (#3692, #3706) | linear: each lookup is indexed or walked once (was a rescan per item) | not measured | `layout::tests_linear`, `math::tests::a_long_text_run_lays_out_in_linear_time` | A word with no break opportunity in it takes a line per few characters, and measuring the word beside a floating object walked the whole paragraph for every one of those lines, so the layout @@ -271,6 +272,9 @@ layout suite are pending; this is not a new Word/native-IME acceptance claim. | 2026-10-24 | trivial | Floating tables count exact line-bottom contact; shapes keep strict overlap (#3977); original boundary/skip regressions in modes 11/12/14/15, expected Word rule from cyril265; readiness estimates unchanged | | 2026-10-23 | trivial | cu20we: preserve and render outline caps across DOCX/VML, screen and PDF; synthetic regressions; readiness estimates unchanged | | 2026-10-23 | trivial | cu20we: paragraph-relative box floats use the pre-spacing origin; header/footer/cell/text-box regressions; readiness estimates unchanged | +| 2026-10-22 | trivial | Layout lookups linear in clipped cells, authors, sections, header paragraphs and bidi offsets (#3692, #3706) | +| 2026-10-22 | trivial | Layout work bounds for hostile documents: linked-box chain widths, repeated header items, `gridAfter`, picture decodes (#3692) | +| 2026-10-22 | trivial | Hostile fonts (#3692): hint-reliant fonts with huge hinting zones are drawn unhinted, and a face draws its later glyphs unhinted once at least three of its hinted draws took over 50 ms and those are at least 1/16 of its hinted draws; a colour glyph's gradient stops and clip paths share one budget | | 2026-10-22 | trivial | Group connector rerouting preserves displayed free and unresolved endpoints under nonuniform scaling while resolved ends keep their connection-site path; degenerate dimensions and unrepresentable fallback display spans are guarded; synthetic model/engine coverage only, readiness estimates unchanged | | 2026-10-21 | trivial | A word with no break opportunity in it, laid out beside a floating object, no longer lays out quadratically: every word is measured once, before the line is broken (#3412) | | 2026-10-23 | trivial | An empty paragraph whose mark is hidden is laid out as nothing, like a deleted one: no room, no list number (still counted, as in Word) and no shading, in the body and in table cells, headers and text boxes (#3982) | diff --git a/docs/mcp.md b/docs/mcp.md index 634691776a..09ab419c16 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -36,7 +36,10 @@ PDF in one run without a window: line win. A command is `{"command": id, "params": {…}}` or just the id. `save` is a path or a list; each output's format follows its extension. Relative `file` and `save` paths resolve against the script's folder (the working folder for stdin). `--cmd` flags run after the script's commands and -`--save` after its outputs. A failing command stops the run with `command N (id): …` and exit +`--save` after its outputs. The run starts with one open document. `file.new` and `file.open` +open another one and make it active; later commands and the saves work on the active document. +`view.switchWindows` lists the open documents (with `--print` their ids show), and +`view.switchWindows={"id": n}` makes one active. A failing command stops the run with `command N (id): …` and exit status 1, and nothing is saved. Every output is checked (known extension, folder exists) before the first is written. Paths inside command params (a picture to insert, say) are relative to the working folder, not the script's. Scripts are capped at 4 MiB. ## Tracked changes by an agent @@ -66,11 +69,16 @@ config works across the suite) keep the agent to the folders you give it: `file.newFromTemplate`, `insert.textFromFile` and `insert.object`, `insert.picture` and `picture.change` with a `path`, `mailings.recipients` with a `path` (csv, tsv, txt, xlsx, xlsm, ods), `review.compare` (`path` and `original`), the templates and documents `tools.updateStyles`, - `styles.organizer` and `styles.copyFrom` read. The pictures an opened HTML file names are read - from beside that file, so they are inside the read root too (a link that leads out is not followed). + `styles.organizer` and `styles.copyFrom` read, and the folders `file.options` takes + (`dictionariesFolder`, `userTemplatesFolder`). The pictures an opened HTML file names are read + from beside that file and must be inside the read root too, even when the file was opened through + a link (#3736). - Every file a command writes must lie inside the **write root**: `save_document`, `file.save`, `file.saveAs`, `file.rename` (the file and its new name), `file.exportPdf`, `file.exportPng`, `file.printPdf`, - `file.saveTemplate`, and `mailings.finish` with a `path`. + `file.saveTemplate`, and `mailings.finish` with a `path`. `font.add` and `font.remove` change the + user font folder, so they are refused when it is outside the write root (#3692). A save replaces + the name it is given, so that name must be inside the write root as well as what it leads to: + a link outside the root pointing in is refused (#3735). - Read and write access are separate. Give the same folder twice for both; a root left out grants none of its access (with only `--automation-read-root`, nothing can be written to disk). Without either flag nothing changes. @@ -109,15 +117,41 @@ files it was given rather than guarding against other software on the computer. | `list_commands` | every command (filter with `query`) — over 270 of them | | `execute` | run one command: `{command: "insert.table", params: {rows: 3, cols: 4}}` | | `batch` | run several commands in order | -| `new_document` | blank, sample, letter, resume, report | +| `new_document` | blank, sample, letter, resume, report; opens beside the open documents | | `open_document` / `save_document` | docx, doc (open only), odt, rtf, html, md, tex, txt, json; save also pdf, png | -| `type_text` | type at the caret (`paragraphs: true` splits lines into paragraphs, in one command and one Undo step: 10,000 lines take ~10 ms; the line breaks are plain breaks, so AutoCorrect doesn't run on them) | +| `list_documents` | the open documents: `id`, `title`, `path`, `dirty`, `active` | +| `type_text` | type at the caret (`paragraphs: true` splits lines into paragraphs: one undo + step; each break is the Enter it stands for, so AutoFormat and AutoCorrect run and a + one-line control refuses; at most 10,000 lines — the cap is `text.insert`'s own, so a + control-channel or CLI call is held to it too) | | `select_text` | select the n-th occurrence of some text | | `get_text` / `inspect_document` | read the document back (verify without screenshots) | | `render_page` | a page as PNG | | `parity` | feature coverage | | `screenshot`, `click`, `key`, `ui_inspect` | app only (`--connect`) | +### Several documents + +`new_document` and `open_document` (the commands `file.new` and `file.open`) open another +document beside the ones already open and make it the active one; their reply carries its `id`. +`open_document` replaces a blank document nobody has used, and a file that is already open becomes +active again (`alreadyOpen`). `list_documents` lists the open documents with their ids. + +Tools work on the active document. `execute`, `batch`, `type_text`, `select_text`, `get_text`, +`inspect_document`, `render_page` and `save_document` also take `doc`, the id of another open +document: the tool works on that document and the active one stays active. + +```json +{"name": "execute", "arguments": {"command": "text.insert", "params": {"text": "Hello"}, "doc": 3}} +``` + +An id that names no open document, or a value that isn't a whole number from 0 up, is a tool error. +`execute` with `view.switchWindows {"id": n}` makes a document active, and `file.closeDocument +{"id": n, "force"?: true}` closes one (unsaved changes need `force`). Through `--connect`, `doc` +reaches the app's control channel (see [Open documents](control-protocol.md#open-documents)); the +app's window keeps showing the document it showed. `screenshot`, `click` and `key` act on the +window, so they work on the document on screen. + `insert.picture` and `insert.icon` leave the caret after the new picture, so typing next doesn't replace it; Picture Format commands (`picture.*`, `arrange.*`) still act on it. diff --git a/docs/recovery.md b/docs/recovery.md index 4431efbb44..dfb307565a 100644 --- a/docs/recovery.md +++ b/docs/recovery.md @@ -6,21 +6,35 @@ unless a document was explicitly created or opened before the startup scan compl In particular, a window launched for New/Open keeps the requested document visible; older copies remain available through File › Recover. Recovery uses the regular interface palette so its labels and buttons stay readable in light and dark themes. -Restore opens an unsaved document with no file path. Save As chooses where to keep it. -Recovery never writes to the original document. AutoSave's permission to write to an earlier -file is cleared on restore. +Restore opens the copy as another unsaved document with no file path, beside the open +documents. It replaces only an untouched blank document, so it never replaces work and asks +nothing. Save As chooses where to keep it. Recovery never writes to the original document. +The restored document has no AutoSave permission to write to an earlier file, and keeps its +copy in the record it came from. -Continue without restoring leaves copies available. Discard asks for confirmation and deletes -the selected copy, including its retained generation. It does not delete the editing document. -After discarding that document's copy, another copy is made only after a further edit. -A successful save removes that document's recovery copy on the next worker cycle while it -remains open. Copies left after switching documents can still be explicitly discarded. +File › Recover lists copies from earlier runs: crashed sessions, documents replaced in this +one, and copies whose deletion failed. The copies the open documents keep are not listed; +they are not lost. Continue without restoring leaves copies available. Discard asks for +confirmation and deletes the selected copy, including its retained generation. Documents with a password do not get plaintext recovery copies. Applying a password waits for any pending snapshot, removes the current document's recovery generations, and fails if cleanup fails. Encrypted recovery is not implemented. Documents opened with a password keep recovery disabled for that session. Removing the password allows future recovery again. +## Several open documents + +Every dirty open document keeps its own copy, whether it is shown or not. A crash with several +unsaved documents leaves a copy of each, and the next start lists them all. + +| Event | That document's copy | +|---|---| +| Save | Deleted on the next worker cycle | +| Close without saving (Don't Save, `file.closeDocument`) | Deleted on the next worker cycle | +| Don't Save when quitting | Deleted before the app exits | +| Clean exit | Waits for the worker, then deletes the copies of documents saved, closed or protected since their last copy; dirty documents keep theirs | +| Crash | Kept | + ## Storage and timing Copies live in `recovery/` under the app's profile folder. `WORDCRAFT_PROFILE_DIR` selects a @@ -28,9 +42,11 @@ separate profile for isolated runs, including preferences, logs and control keys `WORDCRAFT_NO_PREFS` disables recovery as well as preferences. Headless engine/CLI sessions and the browser do not create recovery files implicitly. -The desktop host checks for a dirty document every frame but snapshots at most once per ten -seconds, and only when its revision changes. It clones the shared document model for a bounded -worker queue. Encoding, file I/O, full-document validation and discovery run on the worker. +One worker thread and one store serve every open document, so a record is locked once per +process. Every frame the desktop host compares each open document's revision and timer without +making it active, and queues at most one job. A document is snapshotted at most once per ten +seconds, and only when its revision changes. Only a document whose copy is due is cloned (the +shared document model) for the worker. Encoding, file I/O, full-document validation and discovery run on the worker. An unchanged document does not schedule repeated recovery repaints. The most recent edits can be lost if a crash happens before the next snapshot or while its write is still pending. @@ -68,9 +84,10 @@ interval, English fallback for new labels, and no cloud, version-history or brow `file.recover={}` reads cached `{enabled,busy,documents,error}`. Each document has `id`, `name` and `savedAt`. `{"refresh":true}`, `{"restore":"id"}` and `{"discard":"id"}` queue worker -operations. Poll until `busy` is false; inspect `error` for failure. A restore is cancelled if the -document id or revision changes while it loads. Programmatic restores do not open dialogs; -user restores over dirty work use the existing Save Changes prompt. +operations, and accept only listed ids. Poll until `busy` is false; inspect `error` for failure. +A restore asked for in an untouched blank document replaces it once that document is active, +and is cancelled if the document changes or closes while the copy loads. Restores never open +dialogs. `ui.recoveryDiscard={"id":"id"}` opens confirmation; `{"answer":"discard"}` or `{"answer":"cancel"}` answers it. diff --git a/docs/releasing.md b/docs/releasing.md index 411af7593e..fc2503550a 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -11,7 +11,7 @@ this file has the WordCraft specifics. | Platform | Artifacts | Signing | |---|---|---| | macOS | universal `WordCraft.app` in a `.dmg`, universal CLI zip | Developer ID + notarization (`APPLE_*` secrets) | -| Windows | x64, x86 and arm64 `.msi` + portable `.zip` | Azure Trusted Signing (`AZURE_*` secrets) | +| Windows | x64, x86 and arm64 `.msi` (per-machine, and a per-user `-windows-user-.msi` that needs no admin rights) + portable `.zip` | Azure Trusted Signing (`AZURE_*` secrets) | | Linux | x86_64 and aarch64 `.AppImage` (+ `.zsync` for AppImageUpdate), `.flatpak` bundle, `.deb`, `.rpm`, `.tar.gz`; Flathub manifest | checksums | | Linux riscv64 | `.tar.gz` (cross-compiled on `ubuntu-24.04`, glibc >= 2.39; CLI smoke-tested under QEMU) | checksums | | FreeBSD | x86_64 `.tar.gz` | checksums | diff --git a/docs/shared-pdf.md b/docs/shared-pdf.md index 99b36bb161..92596b59ab 100644 --- a/docs/shared-pdf.md +++ b/docs/shared-pdf.md @@ -2,7 +2,7 @@ WordCraft uses `craft-pdf` with default features disabled and `write` enabled. Both that dependency and the required root Krilla patch use the immutable `storytold/craft-libs` revision -`17744430be327d0257496a6a31c8d38fe20a6756`. Rust 1.95 or newer is required. The revision must be +`b926b5c03e256c86ca10934f58d03f628810b5b7`. Rust 1.95 or newer is required. The revision must be available from the remote before a fresh checkout can resolve it. The shared writer checks a backend marker at compile time so an unpatched Krilla cannot replace the required backend. diff --git a/docs/spelling-dictionaries.md b/docs/spelling-dictionaries.md index 276f1463f0..70354e7e0c 100644 --- a/docs/spelling-dictionaries.md +++ b/docs/spelling-dictionaries.md @@ -1,6 +1,6 @@ # Installing spelling dictionaries -> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-23 · **Change:** minor (Spanish: `es_ES`, `es_MX`, `es_419` and `es_UY` with their fallbacks, #3360, #3553; previously trivial: every Hunspell language shows (no dictionary), not only Polish and Portuguese, and Indonesian is listed, #3150; previously trivial: ISO 8859-1 dictionaries are read, #2824) +> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-23 · **Change:** minor (ISO 8859-9 Turkish dictionaries are read, #3692; previously minor: Spanish: `es_ES`, `es_MX`, `es_419` and `es_UY` with their fallbacks, #3360, #3553; previously trivial: every Hunspell language shows (no dictionary), not only Polish and Portuguese, and Indonesian is listed, #3150; previously trivial: ISO 8859-1 dictionaries are read, #2824) English spelling works out of the box. Polish, Russian, Portuguese, Czech, Italian, Croatian, Turkish, Indonesian and Spanish spelling use a standard **Hunspell** dictionary (an `.aff` and a `.dic` file) that @@ -44,8 +44,9 @@ Spanish regions of the Americas (Argentina, Colombia, the United States…) the American one found. LibreOffice's `dict-es` has them all. Uruguayan Spanish also accepts the `vos` forms of the dictionary's verbs (`tenés`, `vení`, `decime`, `decímelo`). -The files may be UTF-8 or the encoding named on the `.aff`'s `SET` line (ISO 8859-1, ISO 8859-2, KOI8-R -and Windows-1251 are read as well; the original VERO `pt_BR` is ISO 8859-1). +The files may be UTF-8 or the encoding named on the `.aff`'s `SET` line (ISO 8859-1, ISO 8859-2, +ISO 8859-9, KOI8-R and Windows-1251 are read as well; the original VERO `pt_BR` is ISO 8859-1, older +Turkish `tr_TR` dictionaries ISO 8859-9). ## Where WordCraft looks @@ -141,6 +142,7 @@ Never commit dictionary files to this repository: they stay on the user's system |---|---|---| | 2026-10-22 | minor | Spanish (#3360, #3553): the file names for Spain, Mexico, Latin America and Uruguay and their fallbacks | | 2026-10-22 | trivial | Italian, Croatian, Turkish, Russian, Czech and Indonesian without a dictionary now show **(no dictionary)** (or **(dictionary unreadable)**) too, as this page already said; before, only Polish and Portuguese did. Indonesian is listed with its file names (#3150) | +| 2026-10-23 | trivial | ISO 8859-9 (`SET ISO8859-9`) dictionaries are read, so an older Turkish `tr_TR` loads without converting it to UTF-8; the Turkish dictionary is read through the same size-capped reader and `SET` decoding as the others (#3692) | | 2026-10-18 | trivial | ISO 8859-1 (`SET ISO8859-1`) dictionaries are read, so the original VERO `pt_BR` works without converting it to UTF-8 (#2824) | | 2026-10-18 | minor | An installed dictionary that can't be read shows **(dictionary unreadable)** in the status bar, with the file and reason in its tooltip and in File › Options › Proofing (#2830) | | 2026-10-16 | major | First version: the file names per language, the search order and folders on Windows, macOS and Linux, installing `pt_BR` on each, and how to check it works | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6619800d8b..faa97e9ade 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -13,7 +13,7 @@ The generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md). | Number | Value | Kind | |---|---|---| -| Ribbon/menu catalog coverage | **491 / 502 (97.8%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) | +| Ribbon/menu catalog coverage | **487 / 502 (97.0%)** | **measured**: `cargo xtask parity` (re-derived this pass from `crates/engine/src/catalog.rs` against every `CommandSpec::new` id in the source; identical to the checked-in `parity-checklist.md`) | | Feature breadth (weighted, beyond the ribbon: dialog options, citation styles, proofing languages, chart types, UI languages) | **~80%** | estimated | | Feature depth (weighted by use, table below) | **~68%** | estimated | | **Ready for real work** (full target) | **~60%** (range 55–63%) | estimated: weighted sum over the dimension table ([By dimension](#by-dimension)) | @@ -73,7 +73,7 @@ full parity for the area. | Pictures, shapes, text boxes, WordArt | 6 | 60% | 60% | 20–30 | Insert, crop, recolour, styles, floating placement like Word (#136), wrap (square/top-bottom/behind/front), text boxes edit and overflow (#46), VML text boxes (#242), free rotation and flips with a rotation handle (#332), text-box text direction (90°, 270°, stacked), Align Text and linked text boxes (#369), Icons: an original icon library drawn in code with a searchable picker, inserted as pictures and recoloured with Icon Color (#309), Drawing Canvas with Fit/Expand/Scale and DOCX `wpc:wpc` (#344; members edited by command, not yet dragged inside the canvas; the canvas rotates and flips as a whole). Missing: tight/through contour wrap, text in a freely rotated text box, group (#267 open), shape effects, WordArt, connectors, online pictures, screenshot. | | Equations | 1 | 75% | 20% (with Draw) | 4–8 | OMML read/write, OpenType MATH layout, in-place editor with an Equation tab (#191). Missing: line breaking of long display equations, ink equations. | | Charts, SmartArt, Draw/ink, 3D models, icons | 4 | 3% | (20% with equations) | 70–110 | Icons landed (#309; an original library drawn in code). Draw: pens, pencil, highlighter, stroke eraser and Hide Ink (#307), ink saved as DrawingML freeforms; Lasso Select (move, recolour, delete), Add Pen gallery, Ink to Shape (line, arrow, rectangle, ellipse, triangle, polygon) and Ink Replay (#395); Ink to Math, pressure and Word's own InkML missing; charts: Insert › Chart makes column, bar (clustered and stacked), line, area, pie, doughnut and scatter charts with Chart Design (change type, Edit Data grid, title, legend, data labels) and saves them as real chart parts (#409); charts from Word are drawn (#292) and kept on save (#319) but stay read-only; SmartArt is drawn and kept (#292, #319); Insert › SmartArt (#497) creates and edits list, process, cycle, hierarchy, pyramid, radial, matrix and Venn graphics with our own layouts, a Text Pane and the SmartArt Design tab, saved as diagram parts with the drawing; SmartArt from other programs stays read-only. | -| View (modes, zoom, panes, windows) | 3 | 72% | 70% | 6–10 | Print/web/draft/read/focus, dark page (#194), pages side by side when zoomed out (#251), navigation pane; Outline view with the Outlining tab: levels, promote/demote, moving with collapsed content, expand/collapse, Show Level, first line only, text formatting (#493). Switch Windows, Arrange All, View Side by Side and Synchronous Scrolling across WordCraft windows (#322). Missing: New Window on the same document, Split, Master Document, Immersive Reader depth. | +| View (modes, zoom, panes, windows) | 3 | 72% | 70% | 6–10 | Print/web/draft/read/focus, dark page (#194), pages side by side when zoomed out (#251), navigation pane; Outline view with the Outlining tab: levels, promote/demote, moving with collapsed content, expand/collapse, Show Level, first line only, text formatting (#493). Switch Windows, Arrange All, View Side by Side and Synchronous Scrolling across the native windows (#322). Missing: Split, Master Document, Immersive Reader depth. | | Backstage, printing, options | 3 | 58% | 55% | 12–20 | New/Home pages (#254), save prompts (#151), web printing via the browser (#209). Desktop printing still goes through PDF; Options has a fraction of Word's panes. | | Right-to-left and complex scripts | 1 | 50% | 55% | 15–25 | UAX #9, Arabic shaping, RTL paragraphs (#207). No RTL sections/tables, kashida justification or mirrored UI; open bug reports (#215, #211, #199, #66, #63, #48, #19) need re-testing against #207. | | East Asian typography | 1 | 25% | not rated | 13–22 | IME input works (winit fixes pending, #155–#164). Vertical text in sections, text boxes and cells with upright ideographs (#491; no `vert` glyph alternates). Ruby/Phonetic Guide with DOCX round trip (#288); document grid (lines per page, characters per line, #391). Two lines in one and horizontal in vertical (`w:eastAsianLayout`) are kept on save but not laid out. No enclose characters, Asian line-break options. | @@ -251,7 +251,7 @@ can be used without committing Word output. | Date | Change | Summary | |---|---|---| -| 2026-10-11 | trivial | View › Window across WordCraft windows (#322): View catalog 25/29 → 29/29, total 493/504 (97.8%); View row text only, percentages unchanged | +| 2026-10-26 | trivial | View › Window across the native windows (#322): View catalog 26/29 → 29/29; View row text only, percentages unchanged | | 2026-10-18 | trivial | References: Insert Table of Figures dialog; a table of figures lists any caption label (`TOC \c`), without label and number (`\a`), without page numbers (`\n`) or with them after the text (`\p`); Word's switch order is recognised and the leader kept on update (#3013); no percentage change | | 2026-10-15 | trivial | Pictures, shapes, text boxes: Insert › Shapes adds the new shape beside the selection, as Word draws it, so a second shape no longer replaces the first (still selected after its insert) and selected text stays; a picture still replaces a selected picture, as in Word (#2019) | | 2026-10-19 | trivial | Performance row: render threads reused while typing (#3027) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index de32b47b04..243f34733f 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: filter-as-you-type command search in the Customize Ribbon, Quick Access Toolbar and Customize Keyboard pickers, #1845; previously trivial: Find and Replace: Replace with expands regex capture groups, #4177; previously trivial: the mini toolbar marks a right-to-left paragraph's alignment as seen on the page, like the ribbon, #3579; previously trivial: Review › Accept and Reject menus: Accept/Reject and Move to Next, Accept/Reject All Changes and Stop Tracking; previously trivial: Reading Mode: the parity catalog counts Read Mode's View menu, Layout missing; previously trivial: comment times in the user's time zone, in the pane and the balloons, #2322, #2337; previously trivial: Read Mode's Page color: None, Sepia, Inverse; previously trivial: Insert › Table's grid previews the hovered table on the page, #3931; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; 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: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; 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 major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (Custom Document Properties dialog from File › Info › Properties › Advanced Properties…, #2982; previously trivial: View › Window › New Window opens another window on the document, each with its own caret and view; previously trivial: mail merge: First Record, Last Record and a record number box in Preview Results; Finish & Merge asks which records to merge or print, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: read only and comments-only protection grey out edits; previously trivial: the Font dialog previews "NaN" or "inf" in the Size box at 12 pt, #3663; previously trivial: File › Options disables the User name field while Restrict Editing is active; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; 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: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; 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 major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 Recovery discovery no longer interrupts an explicitly created or opened document. Older copies remain in File › Recover, whose buttons use the interface palette. See [local recovery](recovery.md). @@ -35,7 +35,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings wrap onto further lines within the pane instead of expanding it, #759, #3015), Styles, Comments (balloons and cards read in both themes, #2045), Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | | Context menus (right-click) | text, spelling suggestions, fields (Update Field, Edit Field…, Toggle Field Codes, #339; Update Field on a table of contents entry updates the table, #1197, and after a save and reopen, #2182); right-click selects a picture, shape or text box and offers Insert Caption… (#1230) and Wrap Text (styles, Move with Text / Fix Position on Page, #513); tables, the items following the selection as in Word (Insert, Delete › Cells, Columns, Rows or Table whatever is selected (#1198), Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…; no Border Styles yet) | rich per context | 65% | 2–4 | | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings wrap onto further lines within the pane instead of expanding it, #759, #3015), Styles, Comments (times in the user's own time zone, in the pane and the balloons, #2322, #2337; typed comment and reply text stays with its document, is written before Save, Export, Print, Close, New or Open, Escape discards it, and the fields are read-only where comments can't change, #1348), Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | -| Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268), Options in Word's panes with working settings (#485), Print page with preview, page range and copies (#641); launched with nothing to open, the app starts on Home (the Start screen: Blank document, templates, Recent; no document is open until one is picked or opened, so Esc or Back shows an empty window with the ribbon greyed out but for Help, #1129), with File › Options › General › Start-up to turn it off (#766); Info › Inspect Document finds and removes hidden text by its effective formatting (direct, character or paragraph style) in every story: body, headers, footers, notes, comments and text boxes, and lists what it couldn't reach (#1344); File › Close (Mod+W, Mod+F4) closes the document and keeps the window, showing no page, as Word does; the window's close button quits (#1712); each side-menu item has an icon, drawn in code (#1714); Info › Properties › Advanced Properties… opens the Custom Document Properties dialog (#2982); New lists personal templates (`.dotx`/`.dotm`) from the user templates folder under Personal, a subfolder per category, and its search covers them; a click makes an untitled document attached to the template; Save As › Save as Word Template starts in that folder; the folder is set in Options › Advanced › File locations (#2852; no thumbnails yet, no workgroup templates) | | 68% | 4–6 | +| Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268), Options in Word's panes with working settings (#485), Print page with preview, page range and copies (#641); launched with nothing to open, the app starts on Home (the Start screen: Blank document, templates, Recent; no document is open until one is picked or opened, so Esc or Back shows an empty window with the ribbon greyed out but for Help, #1129), with File › Options › General › Start-up to turn it off (#766); Info › Inspect Document finds and removes hidden text by its effective formatting (direct, character or paragraph style) in every story: body, headers, footers, notes, comments and text boxes, and lists what it couldn't reach (#1344); File › New, Open, the mailings and personal templates open their document beside the open ones, so they ask nothing; File › Close (Mod+W, Mod+F4) closes the shown document and keeps the window, showing the next open document, or no page after the last one, as Word does; the window's close button asks about each document with unsaved changes in turn, showing it, and Cancel stops the quit (#1712); each side-menu item has an icon, drawn in code (#1714); Info › Properties › Advanced Properties… opens the Custom Document Properties dialog (#2982); New lists personal templates (`.dotx`/`.dotm`) from the user templates folder under Personal, a subfolder per category, and its search covers them; a click makes an untitled document attached to the template; Save As › Save as Word Template starts in that folder; the folder is set in Options › Advanced › File locations (#2852; no thumbnails yet, no workgroup templates) | | 68% | 4–6 | | Rulers: indents, tabs, margins, table columns | indents draggable (one undo, #62); tab stops: corner selector (Left/Center/Right/Decimal/Bar), click to set, drag to move, drag off to clear, per-kind markers, Alt skips the snap, one undo step per gesture, double-click opens the Tabs dialog (#320); table column and row markers drag their borders (one undo) and in a cell the indents are measured from it (#700); the numbers thin out as the page shrinks on screen (every 1, 2, 4… units, never closer than 30 px, the small ticks never closer than 4 px, the text keeps its size), give way to an indent, tab or column marker on them, and the margins get ticks only; both rulers span the caret's page at its own scale in every layout (one column, One Page, Multiple Pages) and follow the caret to another page (#1878) | ✅ | 92% | 1 | | On-canvas objects: select, drag, resize handles, nudge | ✅ tested (#228); a picture or shape behind text can be dragged across the overlapping text (body and header/footer), whether or not it was selected first, while a click without movement still edits that text | ✅ | 75% | — | | On-canvas objects: select, drag, Ctrl+drag to copy, resize handles, nudge | ✅ tested (#228, #1014); an In Line with Text object drags within the text as in Word: a dashed insertion cursor follows the pointer through the text and the drop (or Ctrl+drop copy) lands there, still inline (#2233, #2234) | ✅ | 75% | — | @@ -46,7 +46,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Selection: word/sentence/paragraph clicks, dragging after a double-click/triple-click/Ctrl+click extends by whole words/paragraphs/sentences (#549), Shift extend, F8 extend mode, column (Alt+drag, #237), selection bar in the left margin (click a line, double-click a paragraph, triple-click or Ctrl+click everything, drag by lines, Shift+click extends; #856) | ✅ Missing: the selection bar in the right margin for right-to-left paragraphs, beside table rows, and Word's right-pointing arrow cursor (egui has none; the plain arrow shows) | ✅ | 92% | 1 | | Headers and footers on the page: double-click the top or bottom margin to edit them, the body to leave | ✅ in Print Layout, also on a page with none yet: the header or footer that page shows is created (first page, even page or the default) and the caret put in it; one left empty is taken away again on leaving and is never saved (#960). Making one is an undo step, refused in a protected document. On a later section's page with no header anywhere it goes in the first section and shows on every section linked to it, as Word links to the previous section by default. With Different First Page (or Odd & Even) on, a double-click goes into the header that page shows even from another header being edited, and Insert › Header/Footer and Edit Header with the caret on page 1 use page 1's own (#2826). Closing it (Esc, Close Header and Footer) puts the caret back where it was in the body, as Word does, instead of on page 1 (#3331); a double-click on the body puts it where clicked. While one is being edited, a single click in the other (the footer while editing the header, or another page's own) edits that one with the caret where clicked, and the view stays put. Web Layout, Draft and Read Mode show no margins and leave it alone. A click in the margin band from the body leaves the caret and the view where they are, so double-clicking a later page's footer opens it there (#2103) | ✅ | 95% | 0.5 | | Zoom: Ctrl+scroll (only zooms; a coast or notch still gliding stops, #1743), pinch (#179), zoom buttons (#232), pages side by side when zoomed out (#251) | ✅ | ✅ | 85% | — | -| Windows (View › Window): Switch Windows, Arrange All, View Side by Side, Synchronous Scrolling (#322) across WordCraft windows, one process each; New Window and Split are placeholders; Wayland ignores placement and may only flash for focus | partial | ✅ | 60% | 2–4 | +| Windows (View › Window): Switch Windows, Arrange All, View Side by Side, Synchronous Scrolling (#322) across the native windows; Split is a placeholder; Wayland ignores placement and may only flash for focus | partial | ✅ | 60% | 2–4 | | Touchpad smooth/inertial scroll | ✅ (#252) | ✅ | 90% | — | | Web Layout width | Text wraps to the whole work area (window width at the current zoom, 144–20,000 pt) and re-wraps once a window resize has settled (not on every frame of a drag) or at once on a zoom, keeping the caret in view; the sheet runs edge to edge with no centred page. Draft and Outline keep their 800 pt column; headless sessions keep 800 pt (#1876) | text across the window | 95% | — | | Scrollbar drag page tip | ✅ while the vertical scrollbar is dragged, a tip beside the pointer names the page at the top of the window in the status bar's words ("Page 7 of 10"), on one line however long the number (#2551, #3341, `canvas::tests::dragging_the_scrollbar_names_the_page_at_the_top`); its look and wording not yet compared with Word's | not compared | 70% | 0.5 | @@ -83,6 +83,9 @@ Footnote and endnote editing keeps body and note text at normal opacity, on ligh | Date | Change | Summary | |---|---|---| +| 2026-10-22 | trivial | View › Window › New Window opens another window on the document, focused and cascaded; two windows on one document are titled `Name:1` and `Name:2` (one window: the plain name). Each window keeps its own selection, scroll position, zoom, view mode, ruler and panes; the text, undo history, unsaved state and file are the document's, so an edit or Undo in either window shows in both, and a caret in text the other window deleted moves to the nearest place left. Closing one of a document's windows asks nothing; closing its last asks about unsaved changes, and quitting asks once per document. Switch Windows lists every window. The web app keeps one window | +| 2026-10-22 | trivial | One native window per open document on the desktop (the web app keeps one window): New and Open open the document in a window of its own, focused and cascaded from the last; Open replaces an untouched blank document in its window and brings an already-open file's window forward; each window keeps its own ribbon tab, File page, dialogs, panes and composition; View › Window › Switch Windows lists the windows and brings the chosen one forward; File › Close and the window's close button close the window (asking about unsaved changes) and the window focused before comes forward, while the last window stays open with no document; quitting asks Save / Don't Save / Cancel about each document in its own window | +| 2026-10-22 | trivial | Several open documents in one window (one window per document comes next): View › Window › Switch Windows lists the open documents, the shown one marked and unsaved ones dotted, and shows the one chosen, with its own scroll position, zoom, comment drafts and AutoSave file; File › New, Open, Envelopes, Labels, Finish & Merge and personal templates open beside the open documents without asking about unsaved changes; File › Close shows the next open document; the window's close button asks Save / Don't Save / Cancel about each document with unsaved changes, showing it, and Cancel keeps the app open. Untitled documents are numbered (Document2) in the interface language | | 2026-10-25 | trivial | Custom Document Properties dialog: File › Info › Properties › Advanced Properties… opens it; the document's custom properties listed with Add, Modify and Delete over a name/value form (text properties only; Word's number/date/yes-no types not offered), OK one undoable `file.properties` step, Cancel discards; modal dialogs 62 → 63 (#2982) | | 2026-10-25 | trivial | Text Highlight Color shows the remembered colour on the ribbon and mini-toolbar buttons; No Color leaves the pen without a misleading yellow bar (#4087) | | 2026-10-24 | trivial | Read Mode: a Page color menu (None, Sepia, Inverse) beside Line spacing; the columns are drawn on white, sepia or a dark page, in place of the document's page color (`view.readPageColor`) | @@ -277,7 +280,7 @@ Footnote and endnote editing keeps body and note text at normal opacity, on ligh | 2026-10-11 | trivial | Interface size setting in File › Options › General (80–200 %), kept between runs and applied on top of the system's display scale; `ui.interfaceSize` for agents (#475) | | 2026-10-11 | trivial | Font list ends with Add Font… (TTF, OTF, TTC, WOFF, WOFF2; also by dropping the file); added fonts are listed at once and kept between runs (#582) | | 2026-10-11 | trivial | Font box (ribbon and mini toolbar): typing lists the matching fonts (prefix first, then any part of the name); Up/Down, Enter or a click picks one, Escape cancels (#359) | -| 2026-10-11 | trivial | View › Window across WordCraft windows: Switch Windows, Arrange All, View Side by Side, Synchronous Scrolling (#322) | +| 2026-10-26 | trivial | View › Window across the native windows: Switch Windows, Arrange All, View Side by Side, Synchronous Scrolling (#322) | | 2026-10-11 | trivial | Templates and Add-ins (template path, Attach…, automatically update document styles) and Organizer (copy styles from another file, delete, rename) dialogs (#377); styles update from the template only when the user asks (OK with the box ticked, or Update Styles), never on open; the three Modal dialogs rows merged into one | | 2026-10-11 | trivial | Field codes (#339): View › Field Codes (Alt+F9), Toggle Field Codes (Shift+F9), a field-code popup (double-click, right-click › Edit Field…; Ctrl+F9 opens the Field dialog), unlink/lock/unlock and next/previous field keys; field items in the context menu | | 2026-10-10 | trivial | Tabs dialog (Paragraph › Tabs…, double-click a ruler tab) and Borders and Shading with Borders, Page Border and Shading tabs (#320) | diff --git a/docs/zotero.md b/docs/zotero.md index f05b62c6e2..4e96520ad5 100644 --- a/docs/zotero.md +++ b/docs/zotero.md @@ -61,6 +61,34 @@ Word's "Field" in the file.) - Zotero's document transfer (Export/Import Document) isn't needed and isn't supported. - The web version can't reach Zotero (browsers can't open the socket). +## Who can drive the document + +Zotero's protocol has no handshake that proves the program on port 23116 is Zotero, and that +program can read the citations and edit the open document. WordCraft limits who that can be. +In short: **every system** gets the loopback rule; **only Linux** also checks that the listener +isn't another user's. On Windows and macOS (and the BSDs) the loopback rule is the only check. + +- Every system: it only connects to this computer (a loopback address such as `127.0.0.1`); + any other address is refused before connecting. +- Linux only: it checks, in the kernel's socket table (`/proc/net/tcp`, `/proc/net/tcp6`), that the + program listening on the port runs as the same user as WordCraft, before sending anything. If + another account on a shared machine took the port while Zotero wasn't running, WordCraft + refuses with an error and the document isn't touched. When the table doesn't show the + listener (WSL2 mirrored networking with Zotero running on Windows, a local proxy), its owner + is unknown and only the loopback rule applies, as below. That holds only when the tables were + read in full: a read error, or an open error other than "not found", **refuses**. Sandboxes + that deny `/proc/net/tcp` (some AppArmor or Snap profiles, for example) are therefore refused. +- Windows, macOS and the BSDs: **no same-user check.** They have no such table to read + without OS calls WordCraft doesn't make (it uses no `unsafe` code), so only the loopback rule + applies there (and on Linux when the listener isn't in the table). Remaining risk: on a + computer shared with other accounts, a program another user leaves on port 23116 while + Zotero is closed would be taken for Zotero. An owner check for these systems is tracked in + #3831. A program running as you is trusted on every system; it could change your documents + anyway. +- `wordcraft-cli zotero` prints Zotero's messages (and `--trace` lines) with control + characters, line breaks and backslashes shown as escapes (`\n`, `\u{1b}`, `\\`), so a peer + can't forge other output or drive the terminal. + ## How it works `crates/zotero` implements Zotero's published word-processor wire protocol (port 23116): diff --git a/packaging/macos/import-cert.sh b/packaging/macos/import-cert.sh index a4e79ea8b4..341d012f62 100755 --- a/packaging/macos/import-cert.sh +++ b/packaging/macos/import-cert.sh @@ -30,7 +30,7 @@ KEYCHAIN="$WORK/wordcraft-signing.keychain-db" cleanup() { local status=$? rm -f "$CERT" - if [ "$status" -ne 0 ]; then + if [[ "$status" -ne 0 ]]; then security delete-keychain "$KEYCHAIN" 2>/dev/null || true rm -rf "$WORK" fi diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index 5223527598..e89a25e432 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -5,6 +5,7 @@ .DESCRIPTION Produces, in $env:DIST (default: dist/release): wordcraft--windows-.msi per-machine installer (WiX v5) + wordcraft--windows-user-.msi per-user installer, no admin rights (#39) wordcraft--windows--portable.zip wordcraft.exe + wordcraft-cli.exe The binaries link the C runtime statically (+crt-static), so neither the MSI nor the portable @@ -142,18 +143,26 @@ $rtf = '{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0\fswiss\fcharset0 Tahoma;}}\vi ($paragraphs -join "\par`r`n") + "\par`r`n}`r`n" [System.IO.File]::WriteAllText($LicenseRtf, $rtf, [System.Text.Encoding]::ASCII) +# Two MSIs from one source: per-machine (Program Files, needs admin) and per-user (#39: no admin, +# into %LOCALAPPDATA%\Programs; see wordcraft.wxs). The per-user name puts "user" before the arch +# so the per-machine MSI sorts first: the shared windows-arm64 workflow installs the first *.msi +# it finds and checks Program Files. $Msi = Join-Path $Dist "wordcraft-$Version-windows-$Arch.msi" -Invoke-Native 'wix build' { - wix build (Join-Path $PSScriptRoot 'wordcraft.wxs') -arch $Arch ` - -ext WixToolset.UI.wixext -ext WixToolset.Util.wixext ` - -d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\wordcraft.ico')" ` - -d "LicenseRtf=$LicenseRtf" ` - -d "AppNoticesDir=$AppNotices" -d "DocsDir=$Docs" -d "HasCraftFontLicences=$HasCraftFontLicences" ` - -o $Msi +$UserMsi = Join-Path $Dist "wordcraft-$Version-windows-user-$Arch.msi" +foreach ($build in @(@($Msi, 'perMachine'), @($UserMsi, 'perUserOrMachine'))) { + $out = $build[0] + Invoke-Native "wix build ($($build[1]))" { + wix build (Join-Path $PSScriptRoot 'wordcraft.wxs') -arch $Arch ` + -ext WixToolset.UI.wixext -ext WixToolset.Util.wixext ` + -d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\wordcraft.ico')" ` + -d "LicenseRtf=$LicenseRtf" ` + -d "AppNoticesDir=$AppNotices" -d "DocsDir=$Docs" -d "HasCraftFontLicences=$HasCraftFontLicences" -d "Scope=$($build[1])" ` + -o $out + } + # wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets. + Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($out, '.wixpdb')) + & (Join-Path $PSScriptRoot 'sign.ps1') $out } -# wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets. -Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($Msi, '.wixpdb')) -& (Join-Path $PSScriptRoot 'sign.ps1') $Msi # ---- portable zip ------------------------------------------------------------------------------ $Portable = Join-Path $TargetDir "windows-package\wordcraft-$Version-windows-$Arch-portable" @@ -182,4 +191,4 @@ if ($Arch -ne 'arm64' -or $HostArch -eq 'arm64') { } else { Write-Output "skipping wordcraft-cli --version: an $Arch build doesn't run on this $HostArch machine" } -Get-Item $Msi, $Zip | Format-Table Name, Length +Get-Item $Msi, $UserMsi, $Zip | Format-Table Name, Length diff --git a/packaging/windows/wordcraft.wxs b/packaging/windows/wordcraft.wxs index 27aa89ca25..5be96705b1 100644 --- a/packaging/windows/wordcraft.wxs +++ b/packaging/windows/wordcraft.wxs @@ -5,16 +5,26 @@ Built by package.ps1: wix build wordcraft.wxs -arch x64 -ext WixToolset.UI.wixext -ext WixToolset.Util.wixext - -d Version=1.2.3 -d BinDir=... -d IconPath=... -d LicenseRtf=... -o out.msi + -d Version=1.2.3 -d BinDir=... -d IconPath=... -d LicenseRtf=... [-d Scope=perUserOrMachine] -o out.msi Per-machine install into Program Files with a Start Menu shortcut. The installer shows the standard WiX dialogs: welcome + license, progress, and a finish page. + Built a second time with -d Scope=perUserOrMachine for people without administrator rights + (#39): a dual-purpose package that WiX gives ALLUSERS=2 and MSIINSTALLPERUSER=1, so it installs + for the current user with no UAC prompt. Windows then redirects Program Files to + %LOCALAPPDATA%\Programs, the Start Menu to the user's own, and HKMU to HKCU, so the same + components work in both packages. Both share the UpgradeCode: each upgrades its own earlier + versions (Windows Installer only matches products installed in the same context). + UpgradeCode and the component GUIDs below belong to WordCraft alone. Never copy them from (or into) a sibling Craft app: Windows Installer treats every package sharing an UpgradeCode as one product, so a shared code makes one app's installer refuse to install ("a newer version is already installed") or silently uninstall the other. packaging-lint.yml checks this. --> + + + @@ -24,7 +34,7 @@ Manufacturer="Learning Machines LLC" Version="$(var.Version)" UpgradeCode="075497F9-E861-45D0-9C7F-A74F8C34E6BA" - Scope="perMachine" + Scope="$(var.Scope)" Compressed="yes" Language="1033"> @@ -88,7 +98,7 @@ Icon="WordcraftIcon.ico" IconIndex="0" /> - + - - - - - - + + + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - + + + - - - + + +