mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-12 17:02:08 -06:00
Compare commits
143
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0ee120ac2 | ||
|
|
bf76e6e63b | ||
|
|
467d36a9c8 | ||
|
|
efc11acede | ||
|
|
0c5436de76 | ||
|
|
ce0beb6900 | ||
|
|
a60db4f1c7 | ||
|
|
a3366cff65 | ||
|
|
591b8354f2 | ||
|
|
0b31cebe5d | ||
|
|
37941f0624 | ||
|
|
b1bd8d8b20 | ||
|
|
794b65ecac | ||
|
|
6308fc9edb | ||
|
|
48659c1668 | ||
|
|
e8845f648a | ||
|
|
3858482465 | ||
|
|
73a18c4f4c | ||
|
|
0b4b36b865 | ||
|
|
4251dad939 | ||
|
|
1b7e4f00dc | ||
|
|
4deedbb549 | ||
|
|
8d4fb0b12e | ||
|
|
203872b4db | ||
|
|
d3954beacf | ||
|
|
26e7112e0f | ||
|
|
3d31729de3 | ||
|
|
3ba4945e72 | ||
|
|
0752f3181b | ||
|
|
b24ec5df6d | ||
|
|
d0c567992e | ||
|
|
1c84e32f58 | ||
|
|
cf3b11c858 | ||
|
|
1e6c8b8b72 | ||
|
|
3e0f073e9a | ||
|
|
eec5db66f0 | ||
|
|
a440cfac28 | ||
|
|
92a79db048 | ||
|
|
652c08a89b | ||
|
|
aeb3fb19ce | ||
|
|
e4ec0d7ed1 | ||
|
|
23487b0cb5 | ||
|
|
0407b63663 | ||
|
|
b45698d109 | ||
|
|
c913bf6615 | ||
|
|
1bd4ca8946 | ||
|
|
0431741359 | ||
|
|
ae7fbd81b7 | ||
|
|
f800472b0a | ||
|
|
9c87c22662 | ||
|
|
c1adf9b239 | ||
|
|
21df49c636 | ||
|
|
7e0d96d74b | ||
|
|
572bec289b | ||
|
|
55eb394044 | ||
|
|
9e9f13114f | ||
|
|
35e9815603 | ||
|
|
6ea8d11522 | ||
|
|
7c3af8a421 | ||
|
|
ca7ea0131e | ||
|
|
aa0347cc2e | ||
|
|
2ff6001d3d | ||
|
|
20917cfd01 | ||
|
|
4dd1c6cf6c | ||
|
|
5cf52c5fbb | ||
|
|
60063b85cd | ||
|
|
40c1afbe84 | ||
|
|
75c07a1a83 | ||
|
|
353908fca5 | ||
|
|
10545dc4e1 | ||
|
|
f8070bbbd0 | ||
|
|
5e5b5d082d | ||
|
|
2bc50341a0 | ||
|
|
7cd0a6cc72 | ||
|
|
14aae7cee1 | ||
|
|
8605fb02f0 | ||
|
|
032b280b04 | ||
|
|
40c3ada226 | ||
|
|
01fe67010d | ||
|
|
86ed3d93b4 | ||
|
|
ef320b988d | ||
|
|
0144ffa51b | ||
|
|
522120c34c | ||
|
|
3e64ddbd80 | ||
|
|
2cae4aa316 | ||
|
|
ed15db4101 | ||
|
|
3d50413aff | ||
|
|
224d0e96d7 | ||
|
|
1baa723ae7 | ||
|
|
cca09cc8f4 | ||
|
|
583c8a4819 | ||
|
|
8e3fba8a25 | ||
|
|
dd5cd48b8f | ||
|
|
2a9a21d41c | ||
|
|
6a22a3b357 | ||
|
|
38f48ad974 | ||
|
|
ac0588dbaf | ||
|
|
a5c74edfec | ||
|
|
d0b40c3b30 | ||
|
|
f74d95542f | ||
|
|
f3e24e2b68 | ||
|
|
5b3b3bdc0e | ||
|
|
f8a764f20c | ||
|
|
0d1296e532 | ||
|
|
b396e6c0bd | ||
|
|
9bd6fa1d75 | ||
|
|
e624f4d178 | ||
|
|
96aece4e57 | ||
|
|
218aa884be | ||
|
|
4f372ff470 | ||
|
|
5ca5e6b61e | ||
|
|
5d440c82ac | ||
|
|
57e0eb5b0f | ||
|
|
71469495bc | ||
|
|
527abcfc7d | ||
|
|
37cf274a03 | ||
|
|
f9bd1e7eca | ||
|
|
5a93bc3311 | ||
|
|
f1d8d2e1e8 | ||
|
|
3afaf39584 | ||
|
|
4777381dec | ||
|
|
97e43b84fa | ||
|
|
6e2ec96c85 | ||
|
|
e6b6d2d65f | ||
|
|
d59c51ba1f | ||
|
|
bce01e7789 | ||
|
|
4b75f3ea00 | ||
|
|
a9069cb8f9 | ||
|
|
fb71f723fc | ||
|
|
caf897daad | ||
|
|
a9cc381332 | ||
|
|
451debe5e8 | ||
|
|
5f53afd72e | ||
|
|
c7e4d6a19c | ||
|
|
30dfc3fbd8 | ||
|
|
8e30c095db | ||
|
|
6090366562 | ||
|
|
c59575b766 | ||
|
|
30ef61982b | ||
|
|
e04e559da3 | ||
|
|
3109f2a9e0 | ||
|
|
a39c6b8977 | ||
|
|
470968fcdc |
@@ -1,6 +1,10 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
# A nightly dry run on main: every release build except macOS, nothing signed or released
|
||||
# (craft-common-actions release.yml `dry-run`). Finds release-only breakage before release day.
|
||||
schedule:
|
||||
- cron: "56 1 * * *"
|
||||
push:
|
||||
branches: [release]
|
||||
workflow_dispatch:
|
||||
@@ -23,6 +27,7 @@ jobs:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
dry-run: ${{ github.event_name == 'schedule' }}
|
||||
app-name: gridcraft
|
||||
app-title: GridCraft
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
|
||||
Generated
+142
-16
@@ -739,6 +739,12 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||
|
||||
[[package]]
|
||||
name = "cmpv2"
|
||||
version = "0.2.0"
|
||||
@@ -757,7 +763,7 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"const-oid 0.9.6",
|
||||
"der",
|
||||
"spki",
|
||||
"x509-cert",
|
||||
@@ -827,6 +833,12 @@ version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be95d4a884358a21a199689323c29e9256b4a1b315e22f183d155422041d3a9a"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
@@ -907,15 +919,28 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-cfb"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
|
||||
[[package]]
|
||||
name = "craft-csv"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=aa84415bae3b765fef6b7ef42404f66ad1cff9d9#aa84415bae3b765fef6b7ef42404f66ad1cff9d9"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=aa84415bae3b765fef6b7ef42404f66ad1cff9d9#aa84415bae3b765fef6b7ef42404f66ad1cff9d9"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
@@ -935,10 +960,38 @@ dependencies = [
|
||||
"writeable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-offcrypto"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.23.1",
|
||||
"craft-cfb",
|
||||
"craft-xml",
|
||||
"getrandom 0.3.4",
|
||||
"hmac",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.1",
|
||||
"thiserror 2.0.21",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-opc"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
dependencies = [
|
||||
"craft-xml",
|
||||
"craft-zip",
|
||||
"log",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=aa84415bae3b765fef6b7ef42404f66ad1cff9d9#aa84415bae3b765fef6b7ef42404f66ad1cff9d9"
|
||||
dependencies = [
|
||||
"craft-winprint-gdi",
|
||||
"log",
|
||||
@@ -949,10 +1002,15 @@ dependencies = [
|
||||
"winsafe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-sheet"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=aa84415bae3b765fef6b7ef42404f66ad1cff9d9#aa84415bae3b765fef6b7ef42404f66ad1cff9d9"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
@@ -972,7 +1030,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -999,7 +1057,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-web-api"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=aa84415bae3b765fef6b7ef42404f66ad1cff9d9#aa84415bae3b765fef6b7ef42404f66ad1cff9d9"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde_json",
|
||||
@@ -1010,7 +1068,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-web-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=aa84415bae3b765fef6b7ef42404f66ad1cff9d9#aa84415bae3b765fef6b7ef42404f66ad1cff9d9"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
@@ -1019,7 +1077,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"objc2 0.6.5",
|
||||
@@ -1035,6 +1093,38 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-xls"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
dependencies = [
|
||||
"craft-cfb",
|
||||
"craft-sheet",
|
||||
"md-5",
|
||||
"miniz_oxide 0.9.1",
|
||||
"sha1 0.10.7",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-xml"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
dependencies = [
|
||||
"quick-xml 0.41.0",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-zip"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"thiserror 2.0.21",
|
||||
"zip 9.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -1115,6 +1205,15 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cursor-icon"
|
||||
version = "1.2.0"
|
||||
@@ -1127,7 +1226,7 @@ version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"const-oid 0.9.6",
|
||||
"der_derive",
|
||||
"flagset",
|
||||
"pem-rfc7468",
|
||||
@@ -1192,7 +1291,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.1",
|
||||
"const-oid 0.10.3",
|
||||
"crypto-common 0.2.2",
|
||||
"ctutils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1883,9 +1984,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2100,6 +2203,7 @@ dependencies = [
|
||||
name = "gridcraft-engine"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"craft-csv",
|
||||
"gridcraft-calc",
|
||||
"gridcraft-chart",
|
||||
"gridcraft-core",
|
||||
@@ -2240,14 +2344,17 @@ dependencies = [
|
||||
name = "gridcraft-xlsx"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"craft-cfb",
|
||||
"craft-csv",
|
||||
"craft-offcrypto",
|
||||
"craft-opc",
|
||||
"craft-xls",
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
"gridcraft-functions",
|
||||
"gridcraft-math",
|
||||
"gridcraft-model",
|
||||
"quick-xml 0.41.0",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.1",
|
||||
"thiserror 2.0.21",
|
||||
"zip 3.0.0",
|
||||
@@ -2352,6 +2459,15 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
|
||||
dependencies = [
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.5.0"
|
||||
@@ -2992,6 +3108,16 @@ version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
@@ -4804,7 +4930,7 @@ checksum = "da90c3d9af638898a5fdc347479b18f950bb0dde11ecf2244f94cd28135da53b"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"base64 0.22.1",
|
||||
"const-oid",
|
||||
"const-oid 0.9.6",
|
||||
"der",
|
||||
"digest 0.10.7",
|
||||
"pem",
|
||||
@@ -4878,7 +5004,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"cmpv2",
|
||||
"cms",
|
||||
"const-oid",
|
||||
"const-oid 0.9.6",
|
||||
"der",
|
||||
"hex",
|
||||
"rand 0.10.3",
|
||||
@@ -4917,7 +5043,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"cms",
|
||||
"const-oid",
|
||||
"const-oid 0.9.6",
|
||||
"hex",
|
||||
"pem",
|
||||
"rustls-pki-types",
|
||||
@@ -6642,7 +6768,7 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"const-oid 0.9.6",
|
||||
"der",
|
||||
"sha1 0.10.7",
|
||||
"signature",
|
||||
|
||||
+15
-7
@@ -26,10 +26,21 @@ gridcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
gridcraft-mcp = { path = "crates/mcp" }
|
||||
gridcraft-pdf = { path = "crates/pdf" }
|
||||
gridcraft-math = { path = "crates/math" }
|
||||
# Every craft-libs crate (craft-update and craft-window here, craft-ui in ui-egui, craft-print in
|
||||
# the app) comes from one craft-libs revision.
|
||||
# Every craft-libs crate (craft-update, craft-window and the file-format crates here, craft-ui in
|
||||
# ui-egui, craft-print in the app, craft-web-api and craft-web-host in the web app) comes from one
|
||||
# craft-libs revision.
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", version = "=0.1.0", features = ["egui"] }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "=0.1.0", features = ["egui"] }
|
||||
# Compound files ([MS-CFB]) and password-encrypted OOXML packages ([MS-OFFCRYPTO]).
|
||||
craft-cfb = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "0.1.0" }
|
||||
craft-offcrypto = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "0.1.0" }
|
||||
# OPC zip packages (parts, relationships, the zip writer) under `crates/xlsx/src/package.rs`.
|
||||
craft-opc = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "0.1.0" }
|
||||
# Delimited text (CSV, TSV) and SYLK; `crates/xlsx/src/csv.rs` and `crates/engine/src/io/sylk.rs`
|
||||
# turn them into workbooks.
|
||||
craft-csv = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "0.1.0" }
|
||||
# Excel 97-2003 (.xls, BIFF8) workbooks; `crates/xlsx/src/xls.rs` maps them onto the model.
|
||||
craft-xls = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "0.1.0" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
@@ -44,9 +55,6 @@ regex = { version = "1", default-features = false, features = ["std", "unicode",
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
sha2 = { version = "0.11", default-features = false }
|
||||
# Decrypting password-protected workbooks ([MS-OFFCRYPTO]): AES and SHA-1, pure Rust (wasm-safe).
|
||||
aes = { version = "0.9", default-features = false }
|
||||
sha1 = { version = "0.11", default-features = false }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
|
||||
# equations: RaTeX (MIT) lays LaTeX out as positioned glyphs, rules and paths. Exact pins: it is
|
||||
@@ -72,7 +80,7 @@ skrifa = { version = "0.44", default-features = false, features = ["std"] }
|
||||
epaint_default_fonts = "0.37"
|
||||
# One native window per workbook: the window list, Window menu titles and the immediate-viewport
|
||||
# driver. Same craft-libs revision as craft-update above.
|
||||
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", default-features = false, features = ["egui"] }
|
||||
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", default-features = false, features = ["egui"] }
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
@@ -198,9 +198,13 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-13 | XLSX export writes a picture used on several sheets once, as one `xl/media` part every drawing points to (it wrote a copy per drawing: a 16-sheet workbook with one 112 KB logo grew from 0.4 to 1.9 MB) (#985) |
|
||||
| 2026-10-13 | XLSX: `[Content_Types].xml`, `_rels/.rels`, the workbook rels and `xl/workbook.xml` are the first zip entries (document properties last), so libmagic and PHP `finfo` detect the file type even with large properties (#1090) |
|
||||
| 2026-10-13 | Conditional formatting: a rule gathers range statistics only when it reads them (top/bottom, above average, duplicates, scales, bars, icons) and borrows them instead of copying them per cell, so the per-frame lookup of an expression or cell-value rule no longer visits its whole range (#1098, part 1) |
|
||||
| 2026-10-13 | Grid: resizing several selected columns or rows previews on the dragged one and applies the size to all on release; Esc cancels (#1082) |
|
||||
| 2026-10-12 | Next/previous sheet navigation stops at the first/last visible worksheet instead of activating a hidden boundary sheet; the current selection is retained |
|
||||
| 2026-10-12 | PDF writer: objects are written straight to the output, identical pictures are stored once, style operators are emitted only when they change, `q` nesting is limited to 28 and polyline joins no longer leak into later strokes (#816) |
|
||||
| 2026-10-12 | Insert › Text › WordArt (`insert.wordArt`, `wordArt.set`): floating decorative text with 10 original styles, outline and shadow, in-place editing, XLSX and print (#784) |
|
||||
| 2026-10-12 | Number formatting: one table of built-in formats (xlsx); numbers, dates and General render with one scratch buffer instead of per-token strings; output unchanged, checked against digests recorded from the old code (#817) |
|
||||
| 2026-10-21 | Macro engine ([macros.md](docs/macros.md)): typed, declarative macros saved in the workbook (a JSON field; hidden names in xlsx); declared inputs checked with messages that name the cells; tokens (`Input.col(2)`, `Row`, `Output`, `Name:Total`, `{{…}}` in formulas); one undo step with full rollback; a footprint dry run with ask, fail, overwrite, shift down or shift right; a policy that keeps macros away from files and the app, with approval for macros in opened files; Repeat, If and Stop steps; a debugger; a relative recorder; `macro.*` commands beside the unchanged Automate scripts |
|
||||
| 2026-10-13 | Performance: a chart is resolved and rendered once per change of its workbook, zoom or fonts instead of on every frame; idle frames, hovering and scrolling reuse it (#1139, part of #1098) |
|
||||
| 2026-10-13 | Charts: work and memory are bounded by series x points (2,000,000 per chart: every series keeps its first points; 16 trendlines per chart), hairline columns and bars are merged per pixel column, series ranges are clipped before they are evaluated, the moving average is linear and rendering clean data no longer copies it (#1147) |
|
||||
| 2026-10-13 | Charts: one combo plot-kind rule for resolving and drawing; ticks of a scale off the step grid keep their own values (#1146) |
|
||||
| 2026-10-12 | Security: the `--control` server requires an auth token (random, kept in a 0600 file or chosen with `GRIDCRAFT_CONTROL_TOKEN`) on each connection's first line and closes bad connections (#777) |
|
||||
@@ -313,6 +317,9 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-13 | trivial | Progress log: shared pictures written once on XLSX export (#985) |
|
||||
| 2026-10-13 | minor | Progress log: conditional-format lookups no longer scan the range for rules without statistics (#1098) |
|
||||
| 2026-10-12 | minor | Progress log: hidden-sheet navigation at workbook boundaries |
|
||||
| 2026-10-13 | trivial | Progress log: charts cached between frames (#1139) |
|
||||
| 2026-10-12 | minor | Progress log: PDF writer cleanup (#816) |
|
||||
| 2026-10-12 | minor | Catalog 276 → 277 / 292: Insert › WordArt (#784) |
|
||||
| 2026-10-12 | minor | Numfmt cleanup: one built-in format table (gridcraft-xlsx), fewer allocations rendering numbers, dates and General, equality test against the previous output (#817); progress log row |
|
||||
| 2026-10-12 | minor | The control server requires an auth token and closes bad connections (#777); progress log row |
|
||||
| 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row |
|
||||
|
||||
@@ -214,7 +214,7 @@ pub fn main(args: &[String]) -> ExitCode {
|
||||
Err(_) => return usage(format!("--max-report needs a whole number, not `{n}`")),
|
||||
}
|
||||
}
|
||||
let Discovery { files, failures: mut reports } = collect_inputs(&a.positional);
|
||||
let Discovery { files, failures: mut reports, .. } = collect_inputs(&a.positional);
|
||||
reports.extend(files.iter().map(|p| {
|
||||
let path = p.to_string_lossy().into_owned();
|
||||
match gridcraft_engine::io::read_file(&path) {
|
||||
@@ -233,6 +233,9 @@ pub fn main(args: &[String]) -> ExitCode {
|
||||
#[derive(Default)]
|
||||
struct Discovery {
|
||||
files: Vec<PathBuf>,
|
||||
/// Identity (dev+inode) of each collected file, so a rescan reached by a shallower path doesn't add it twice.
|
||||
#[cfg(unix)]
|
||||
file_ids: std::collections::HashSet<(u64, u64)>,
|
||||
failures: Vec<FileReport>,
|
||||
}
|
||||
|
||||
@@ -257,11 +260,11 @@ impl Discovery {
|
||||
/// (`~$…`) are skipped.
|
||||
fn collect_inputs(inputs: &[String]) -> Discovery {
|
||||
let mut out = Discovery::default();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut seen = std::collections::HashMap::new();
|
||||
for i in inputs {
|
||||
let p = PathBuf::from(i);
|
||||
if p.is_dir() {
|
||||
if !walk(&p, 0, &mut out, &mut seen) {
|
||||
if !walk(&p, 0, false, &mut out, &mut seen) {
|
||||
break;
|
||||
}
|
||||
} else if !out.add_file(p) {
|
||||
@@ -277,19 +280,29 @@ fn collect_inputs(inputs: &[String]) -> Discovery {
|
||||
out
|
||||
}
|
||||
|
||||
/// Returns false only when the global file budget stops traversal.
|
||||
fn walk(dir: &Path, depth: usize, out: &mut Discovery, seen: &mut std::collections::HashSet<u64>) -> bool {
|
||||
// A directory is visited once by identity (dev+inode on unix): several links to the same
|
||||
// directory are aliases, not exponential branches of the scan. Other platforms keep the
|
||||
// depth cap only.
|
||||
/// Returns false only when the global file budget stops traversal. `seen` holds the shallowest
|
||||
/// depth each directory was scanned from; `rescan` is set below a directory reached again from a
|
||||
/// shallower path (its depth-limited subtree may now fit), where already collected files are skipped.
|
||||
#[cfg_attr(not(unix), allow(unused_mut))]
|
||||
fn walk(dir: &Path, depth: usize, mut rescan: bool, out: &mut Discovery, seen: &mut std::collections::HashMap<(u64, u64), usize>) -> bool {
|
||||
// A directory is visited once by identity (dev+inode on unix) unless reached from a shallower
|
||||
// path: several links to the same directory are aliases, not exponential branches of the scan
|
||||
// (each directory is scanned at most MAX_DEPTH + 1 times). Other platforms keep the depth cap only.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt as _;
|
||||
if let Ok(m) = std::fs::metadata(dir)
|
||||
&& let Some(id) = m.dev().checked_mul(1 << 60).and_then(|d| d.checked_add(m.ino()))
|
||||
&& !seen.insert(id)
|
||||
{
|
||||
return true;
|
||||
if let Ok(m) = std::fs::metadata(dir) {
|
||||
let identity = (m.dev(), m.ino());
|
||||
// A directory past the depth limit is not scanned, so it is not recorded: asked for
|
||||
// by name later, it is walked from depth 0.
|
||||
match seen.get(&identity) {
|
||||
Some(&shallowest) if shallowest <= depth => return true,
|
||||
Some(_) => rescan = true,
|
||||
None => {}
|
||||
}
|
||||
if depth <= MAX_DEPTH {
|
||||
seen.insert(identity, depth);
|
||||
}
|
||||
}
|
||||
}
|
||||
if depth > MAX_DEPTH {
|
||||
@@ -324,13 +337,20 @@ fn walk(dir: &Path, depth: usize, out: &mut Discovery, seen: &mut std::collectio
|
||||
}
|
||||
};
|
||||
if metadata.is_dir() {
|
||||
if !walk(&p, depth + 1, out, seen) {
|
||||
if !walk(&p, depth + 1, rescan, out, seen) {
|
||||
return false;
|
||||
}
|
||||
} else if p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("xlsx") || e.eq_ignore_ascii_case("xlsm")) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt as _;
|
||||
if !out.file_ids.insert((metadata.dev(), metadata.ino())) && rescan {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if !out.add_file(p) {
|
||||
return false;
|
||||
}
|
||||
} else if p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("xlsx") || e.eq_ignore_ascii_case("xlsm"))
|
||||
&& !out.add_file(p)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
|
||||
@@ -11,8 +11,8 @@ fn discovery_reports_a_directory_read_error() {
|
||||
let path = std::env::temp_dir().join(format!("gridcraft-check-not-a-directory-{}", std::process::id()));
|
||||
std::fs::write(&path, "not a directory").unwrap();
|
||||
let mut discovery = Discovery::default();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
assert!(walk(&path, 0, &mut discovery, &mut seen));
|
||||
let mut seen = std::collections::HashMap::new();
|
||||
assert!(walk(&path, 0, false, &mut discovery, &mut seen));
|
||||
assert!(discovery.files.is_empty());
|
||||
assert_eq!(discovery.failures.len(), 1);
|
||||
assert_eq!(discovery.failures[0].path, path.to_string_lossy());
|
||||
@@ -31,6 +31,47 @@ fn discovery_reports_omissions_only_after_the_file_limit() {
|
||||
assert!(discovery.failures[0].error.as_ref().unwrap().contains("file limit"));
|
||||
}
|
||||
|
||||
/// A new directory under the temp dir: `create_dir` fails on a name somebody else made first.
|
||||
#[cfg(unix)]
|
||||
fn fresh_dir(tag: &str) -> PathBuf {
|
||||
let mut n = 0u32;
|
||||
loop {
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-check-{tag}-{}-{n}", std::process::id()));
|
||||
match std::fs::create_dir(&dir) {
|
||||
Ok(()) => return dir,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => n += 1,
|
||||
Err(e) => panic!("{e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A directory named again by a deeper path, or by a link back to a parent, lists each workbook once.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn discovery_lists_each_workbook_once_whatever_path_reaches_it() {
|
||||
let dir = fresh_dir("once");
|
||||
let sub = dir.join("sub");
|
||||
std::fs::create_dir(&sub).unwrap();
|
||||
std::fs::write(dir.join("a.xlsx"), b"x").unwrap();
|
||||
std::fs::write(sub.join("b.xlsx"), b"x").unwrap();
|
||||
std::os::unix::fs::symlink(&dir, sub.join("loop")).unwrap();
|
||||
let names = |d: &Discovery| {
|
||||
let mut v: Vec<String> = d.files.iter().map(|p| p.file_name().unwrap().to_string_lossy().into_owned()).collect();
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
// The link back to the top is one directory, not another branch of the scan.
|
||||
let found = collect_inputs(&[dir.to_string_lossy().into_owned()]);
|
||||
assert_eq!(names(&found), ["a.xlsx", "b.xlsx"]);
|
||||
// `sub` named after its parent is reached from a shallower depth: scanned again, nothing listed twice.
|
||||
let found = collect_inputs(&[dir.to_string_lossy().into_owned(), sub.to_string_lossy().into_owned()]);
|
||||
assert_eq!(names(&found), ["a.xlsx", "b.xlsx"]);
|
||||
// Named first, the same `sub` is the shallowest path and the parent's scan skips it.
|
||||
let found = collect_inputs(&[sub.to_string_lossy().into_owned(), dir.to_string_lossy().into_owned()]);
|
||||
assert_eq!(names(&found), ["a.xlsx", "b.xlsx"]);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
fn at(a1: &str) -> CellRef {
|
||||
CellRef::parse(a1).unwrap()
|
||||
}
|
||||
|
||||
@@ -205,6 +205,44 @@ fn check_reports_a_depth_limited_scan_while_checking_readable_files() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_directory_past_the_depth_limit_is_still_scanned_when_named_as_an_input() {
|
||||
let dir = tmpdir("check-depth-named");
|
||||
// The first folder past the limit (33 levels down): the scan from the top reports it unreached.
|
||||
let mut deep = dir.clone();
|
||||
for _ in 0..33 {
|
||||
deep.push("d");
|
||||
}
|
||||
std::fs::create_dir_all(&deep).unwrap();
|
||||
ok(&["run", "--out", deep.join("deep.xlsx").to_str().unwrap(), "--quiet"]);
|
||||
let out = run(&["check", dir.to_str().unwrap(), deep.to_str().unwrap(), "--json"]);
|
||||
let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
// The first scan reports the subtree it could not reach; the named directory is checked.
|
||||
assert_eq!(report["totals"]["opened"], 1, "{report}");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_directory_first_reached_at_the_depth_limit_is_completed_when_named_as_an_input() {
|
||||
let dir = tmpdir("check-depth-shallower");
|
||||
// The 32nd folder is scanned from the top, but its child (and the workbook in it) is past the limit.
|
||||
let mut limit = dir.clone();
|
||||
for _ in 0..32 {
|
||||
limit.push("d");
|
||||
}
|
||||
let child = limit.join("c");
|
||||
std::fs::create_dir_all(&child).unwrap();
|
||||
ok(&["run", "--out", limit.join("at_limit.xlsx").to_str().unwrap(), "--quiet"]);
|
||||
ok(&["run", "--out", child.join("below.xlsx").to_str().unwrap(), "--quiet"]);
|
||||
let out = run(&["check", dir.to_str().unwrap(), limit.to_str().unwrap(), "--json"]);
|
||||
let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
// Named from depth 0 the subtree is read: the workbook below the limit is checked, the one at it only once.
|
||||
assert_eq!(report["totals"]["opened"], 2, "{report}");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// A script line whose parameters are under a key `run` does not read used to run the command
|
||||
/// with no parameters at all, and the run still exited 0: three mistaken lines made Sheet2,
|
||||
/// Sheet3 and Sheet4 instead of the named sheets, and nothing said so (#2256).
|
||||
|
||||
@@ -18,8 +18,8 @@ gridcraft-engine = { workspace = true }
|
||||
gridcraft-ui-egui = { workspace = true }
|
||||
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
|
||||
# The page command API (storytold/craft-libs#413): window.gridcraft over the control channel.
|
||||
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "aa84415bae3b765fef6b7ef42404f66ad1cff9d9", version = "0.1.0" }
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "aa84415bae3b765fef6b7ef42404f66ad1cff9d9", version = "0.1.0" }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -35,7 +35,7 @@ winit = { version = "0.30", default-features = false, features = ["x11", "waylan
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
# Same craft-libs revision as the workspace's craft-update and craft-window (see the root
|
||||
# Cargo.toml).
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "aa84415bae3b765fef6b7ef42404f66ad1cff9d9" }
|
||||
craft-update = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
arboard = { workspace = true, features = ["image-data"] }
|
||||
|
||||
@@ -26,7 +26,7 @@ use serde::{Deserialize, Serialize};
|
||||
pub use axis::{Scale, format_number, nice_scale};
|
||||
pub use raster::rasterize;
|
||||
pub use render::{Swatch, legend_entries, render, supports_trendline};
|
||||
pub use resolve::{default_palette, resolve, series_color, series_plot_kind};
|
||||
pub use resolve::{default_palette, has_volatile_series, resolve, series_color, series_plot_kind};
|
||||
pub use sparkline::render_sparkline;
|
||||
|
||||
/// Straight (non-premultiplied) RGBA colour.
|
||||
|
||||
+126
-5
@@ -155,6 +155,131 @@ fn eval_list_in(wb: &Workbook, sheet: usize, formula: &str, limit: usize, depth:
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a series formula of `ch` (chart of `sheet`) calls a function whose result changes
|
||||
/// between evaluations (`NOW`, `TODAY`, `RAND`, `RANDBETWEEN`, `RANDARRAY`), so a result resolved
|
||||
/// earlier is stale. Defined names the formulas use (`=FreshClock`, `Sheet1!Rates`, a LAMBDA name)
|
||||
/// are followed into their own formulas, the way the evaluator looks them up; each name once per
|
||||
/// chart, so a cycle ends. Names nested deeper than [`MAX_NAME_DEPTH`], or more than
|
||||
/// [`MAX_NAMES_FOLLOWED`] distinct names, count as volatile: a chart whose stability isn't
|
||||
/// established is not cached.
|
||||
pub fn has_volatile_series(wb: &Workbook, sheet: usize, ch: &Chart) -> bool {
|
||||
let mut seen = Seen::new();
|
||||
ch.series.iter().any(|s| {
|
||||
[s.name.as_deref(), s.categories.as_deref(), Some(s.values.as_str()), s.bubble_sizes.as_deref()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|f| formula_volatile(wb, sheet, f, 0, &mut seen))
|
||||
})
|
||||
}
|
||||
|
||||
/// Names followed inside names before a formula counts as volatile.
|
||||
const MAX_NAME_DEPTH: u32 = 8;
|
||||
/// Distinct defined names followed per chart before the chart counts as volatile.
|
||||
const MAX_NAMES_FOLLOWED: usize = 64;
|
||||
|
||||
/// Defined names already followed for one chart, each with the sheet its formula is read on.
|
||||
type Seen<'a> = Vec<(&'a gridcraft_model::DefinedName, usize)>;
|
||||
|
||||
fn formula_volatile<'a>(wb: &'a Workbook, sheet: usize, f: &str, depth: u32, seen: &mut Seen<'a>) -> bool {
|
||||
if calls_volatile(f) {
|
||||
return true;
|
||||
}
|
||||
name_tokens(f).into_iter().any(|t| {
|
||||
let Some((n, at)) = defined_name(wb, sheet, t) else { return false };
|
||||
// Followed already, or being followed (a cycle): a volatile call in it is found there.
|
||||
if seen.iter().any(|&(m, s)| std::ptr::eq(m, n) && s == at) {
|
||||
return false;
|
||||
}
|
||||
if depth >= MAX_NAME_DEPTH || seen.len() >= MAX_NAMES_FOLLOWED {
|
||||
return true;
|
||||
}
|
||||
seen.push((n, at));
|
||||
formula_volatile(wb, at, &n.formula, depth + 1, seen)
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether formula text `f` calls one of the volatile functions by name.
|
||||
fn calls_volatile(f: &str) -> bool {
|
||||
const VOLATILE: [&str; 5] = ["NOW", "TODAY", "RAND", "RANDBETWEEN", "RANDARRAY"];
|
||||
// Formulas read from a file may carry the `_xlfn.` prefix of newer functions.
|
||||
let up = f.to_ascii_uppercase().replace("_XLFN.", "");
|
||||
VOLATILE.iter().any(|name| {
|
||||
up.match_indices(name).any(|(i, _)| {
|
||||
let before = up[..i].chars().next_back();
|
||||
let after = up[i + name.len()..].trim_start();
|
||||
before.is_none_or(|c| !(c.is_alphanumeric() || c == '_' || c == '.')) && after.starts_with('(')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn is_ident_char(c: char) -> bool {
|
||||
c.is_alphanumeric() || c == '_' || c == '.' || c == '\\'
|
||||
}
|
||||
|
||||
/// End of the identifier starting at byte `from` of `f`.
|
||||
fn ident_end(f: &str, from: usize) -> usize {
|
||||
f[from..].char_indices().find(|&(_, c)| !is_ident_char(c)).map_or(f.len(), |(j, _)| from + j)
|
||||
}
|
||||
|
||||
/// End (past the closing quote) of the `q`-quoted text opening at byte `from`; a doubled quote
|
||||
/// is an escaped one. `None` when it is not closed.
|
||||
fn quoted_end(f: &str, from: usize, q: char) -> Option<usize> {
|
||||
let mut i = from + q.len_utf8();
|
||||
loop {
|
||||
let j = i + f[i..].find(q)?;
|
||||
if f[j + q.len_utf8()..].starts_with(q) {
|
||||
i = j + 2 * q.len_utf8();
|
||||
} else {
|
||||
return Some(j + q.len_utf8());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The words of formula text `f` that may be defined names: `Name`, `Sheet1!Name`,
|
||||
/// `'Bob''s'!Name`. String literals and numbers are skipped; function names, column letters and
|
||||
/// the like come along too and simply aren't found as names.
|
||||
fn name_tokens(f: &str) -> Vec<&str> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while let Some(c) = f[i..].chars().next() {
|
||||
let start = i;
|
||||
let end = match c {
|
||||
'"' => {
|
||||
i = quoted_end(f, i, '"').unwrap_or(f.len());
|
||||
continue;
|
||||
}
|
||||
'\'' => quoted_end(f, i, '\'').unwrap_or(f.len()),
|
||||
c if is_ident_char(c) => ident_end(f, i),
|
||||
c => {
|
||||
i += c.len_utf8();
|
||||
continue;
|
||||
}
|
||||
};
|
||||
i = end;
|
||||
if f[end..].starts_with('!') {
|
||||
let e = ident_end(f, end + 1);
|
||||
if e > end + 1 {
|
||||
out.push(&f[start..e]);
|
||||
}
|
||||
i = e;
|
||||
} else if c != '\'' && !c.is_ascii_digit() {
|
||||
out.push(&f[start..end]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The defined name `t` (`Name` or `Sheet1!Name`) seen from `sheet`, and the sheet its own
|
||||
/// formula is read on.
|
||||
fn defined_name<'a>(wb: &'a Workbook, sheet: usize, t: &str) -> Option<(&'a gridcraft_model::DefinedName, usize)> {
|
||||
// `'Bob''s'!Name`: the sheet is unquoted as everywhere else (#2012).
|
||||
let (at, bare) = match gridcraft_core::split_sheet(t) {
|
||||
(Some(s), bare) => (wb.sheet_index(&s)?, bare),
|
||||
(None, bare) => (sheet, bare),
|
||||
};
|
||||
wb.name(bare, at).map(|n| (n, n.scope.unwrap_or(at)))
|
||||
}
|
||||
|
||||
fn to_num(v: &Value) -> Option<f64> {
|
||||
match v {
|
||||
Value::Number(n) if n.is_finite() => Some(*n),
|
||||
@@ -305,11 +430,7 @@ fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str, limi
|
||||
/// Whether `t` names a defined name seen from `sheet` (`Name`, or `Sheet1!Name` looked up on
|
||||
/// that sheet), the way the evaluator looks names up.
|
||||
fn is_defined_name(wb: &Workbook, sheet: usize, t: &str) -> bool {
|
||||
// `'Bob''s'!Name`: the sheet is unquoted as everywhere else (#2012).
|
||||
match gridcraft_core::split_sheet(t) {
|
||||
(Some(s), bare) => wb.sheet_index(&s).is_some_and(|i| wb.name(bare, i).is_some()),
|
||||
(None, _) => wb.name(t, sheet).is_some(),
|
||||
}
|
||||
defined_name(wb, sheet, t).is_some()
|
||||
}
|
||||
|
||||
/// A series name: a reference/formula or a defined name (`[0]!Title` in a file) is evaluated,
|
||||
|
||||
@@ -1680,3 +1680,96 @@ fn a_series_named_by_a_name_on_a_sheet_with_a_bang_resolves() {
|
||||
let d = resolve(&wb, 0, &c);
|
||||
assert_eq!(d.series.first().map(|s| s.name.as_str()), Some("Takings"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volatile_series_formulas_are_detected() {
|
||||
let wb = Workbook::new();
|
||||
let with = |values: &str, name: Option<&str>| {
|
||||
let mut c = chart(ChartKind::Line);
|
||||
c.series.push(Series {
|
||||
name: name.map(Into::into),
|
||||
categories: None,
|
||||
values: values.into(),
|
||||
bubble_sizes: None,
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
smooth: false,
|
||||
quartiles: Default::default(),
|
||||
trendlines: vec![],
|
||||
});
|
||||
c
|
||||
};
|
||||
assert!(has_volatile_series(&wb, 0, &with("=NOW()", None)));
|
||||
assert!(has_volatile_series(&wb, 0, &with("=SUM(A1:A3)*rand ()", None)));
|
||||
assert!(has_volatile_series(&wb, 0, &with("Sheet1!$B$2:$B$9", Some("=TODAY()"))));
|
||||
assert!(has_volatile_series(&wb, 0, &with("=RANDARRAY(5)", None)));
|
||||
assert!(has_volatile_series(&wb, 0, &with("=_xlfn.RANDARRAY(5)", None)));
|
||||
assert!(!has_volatile_series(&wb, 0, &with("Sheet1!$B$2:$B$9", Some("Sheet1!$B$1"))));
|
||||
assert!(!has_volatile_series(&wb, 0, &with("=MYRAND(A1)", None)));
|
||||
assert!(!has_volatile_series(&wb, 0, &with("=Sheet1!$NOW", None)));
|
||||
assert!(!has_volatile_series(&wb, 0, &chart(ChartKind::Line)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volatile_defined_names_are_followed() {
|
||||
let with = |values: &str| {
|
||||
let mut c = chart(ChartKind::Line);
|
||||
c.series.push(Series {
|
||||
name: None,
|
||||
categories: None,
|
||||
values: values.into(),
|
||||
bubble_sizes: None,
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
smooth: false,
|
||||
quartiles: Default::default(),
|
||||
trendlines: vec![],
|
||||
});
|
||||
c
|
||||
};
|
||||
let name = |name: &str, scope: Option<usize>, formula: &str| gridcraft_model::DefinedName {
|
||||
name: name.into(),
|
||||
scope,
|
||||
formula: formula.into(),
|
||||
comment: String::new(),
|
||||
hidden: false,
|
||||
};
|
||||
let mut wb = Workbook::new();
|
||||
wb.sheet_mut(0).unwrap().name = "Bob's".into();
|
||||
wb.names.push(name("FreshClock", None, "NOW()"));
|
||||
wb.names.push(name("Later", None, "FreshClock+1"));
|
||||
wb.names.push(name("Dice", Some(0), "RANDBETWEEN(1,6)"));
|
||||
wb.names.push(name("Rates", None, "'Bob''s'!$A$1:$A$3"));
|
||||
wb.names.push(name("Loop", None, "Loop+1"));
|
||||
wb.names.push(name("Spin", None, "Spin+NOW()"));
|
||||
wb.names.push(name("Roll", None, "LAMBDA(n,n*RAND())"));
|
||||
// A workbook-scoped name, a name used inside another, and a sheet-scoped one, bare or qualified.
|
||||
assert!(has_volatile_series(&wb, 0, &with("=FreshClock")));
|
||||
assert!(has_volatile_series(&wb, 0, &with("=SUM(Later, 1)")));
|
||||
assert!(has_volatile_series(&wb, 0, &with("=Dice*2")));
|
||||
assert!(has_volatile_series(&wb, 0, &with("='Bob''s'!Dice")));
|
||||
assert!(has_volatile_series(&wb, 0, &with("=Roll(3)")));
|
||||
// A self-referencing name ends (it evaluates to an error, not a changing value); one that
|
||||
// also calls NOW is volatile.
|
||||
assert!(!has_volatile_series(&wb, 0, &with("=Loop")));
|
||||
assert!(has_volatile_series(&wb, 0, &with("=Spin")));
|
||||
// Many series over the same stable name stay cacheable: each name is followed once.
|
||||
let mut many = with("=Rates");
|
||||
let first = many.series[0].clone();
|
||||
many.series.extend(std::iter::repeat_n(first, 100));
|
||||
assert!(!has_volatile_series(&wb, 0, &many));
|
||||
// Names nested deeper than the limit are not known to be stable.
|
||||
for i in 0..10 {
|
||||
wb.names.push(name(&format!("Deep{i}"), None, &format!("Deep{}", i + 1)));
|
||||
}
|
||||
assert!(has_volatile_series(&wb, 0, &with("=Deep0")));
|
||||
// A name that refers to cells, a name inside a string and a name of another scope are stable.
|
||||
assert!(!has_volatile_series(&wb, 0, &with("=Rates")));
|
||||
assert!(!has_volatile_series(&wb, 0, &with("=\"FreshClock\"")));
|
||||
// Dice belongs to sheet 0: seen from another sheet it isn't defined.
|
||||
wb.sheets.push(Arc::new(gridcraft_model::Sheet::new("Other")));
|
||||
assert!(!has_volatile_series(&wb, 1, &with("=Dice")));
|
||||
assert!(has_volatile_series(&wb, 1, &with("=FreshClock")));
|
||||
}
|
||||
|
||||
@@ -147,10 +147,10 @@ impl RangeRef {
|
||||
RangeRef::new(CellRef::new(0, 0), CellRef::new(MAX_ROWS - 1, MAX_COLS - 1))
|
||||
}
|
||||
pub fn height(&self) -> u32 {
|
||||
self.end.row - self.start.row + 1
|
||||
self.end.row.saturating_sub(self.start.row).saturating_add(1)
|
||||
}
|
||||
pub fn width(&self) -> u32 {
|
||||
self.end.col - self.start.col + 1
|
||||
self.end.col.saturating_sub(self.start.col).saturating_add(1)
|
||||
}
|
||||
pub fn count(&self) -> u64 {
|
||||
self.height() as u64 * self.width() as u64
|
||||
@@ -321,6 +321,13 @@ pub fn split_areas(reference: &str) -> Vec<&str> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hostile_range_dimensions_saturate_instead_of_wrapping() {
|
||||
let r = RangeRef { start: CellRef::new(0, 0), end: CellRef::new(u32::MAX, u32::MAX) };
|
||||
assert_eq!((r.height(), r.width()), (u32::MAX, u32::MAX));
|
||||
assert_eq!(r.count(), u64::from(u32::MAX) * u64::from(u32::MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn areas_split_only_at_commas_outside_quoted_sheet_names() {
|
||||
assert_eq!(split_areas("B2:B4,D1"), ["B2:B4", "D1"]);
|
||||
|
||||
@@ -21,6 +21,7 @@ web-time = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
gridcraft-xlsx = { workspace = true }
|
||||
craft-csv = { workspace = true }
|
||||
gridcraft-chart = { workspace = true }
|
||||
gridcraft-pdf = { workspace = true }
|
||||
gridcraft-math = { workspace = true }
|
||||
|
||||
@@ -26,6 +26,7 @@ pub mod spelling;
|
||||
pub mod stash;
|
||||
pub mod view;
|
||||
pub mod whatif;
|
||||
pub mod wordart;
|
||||
|
||||
use gridcraft_calc::Key;
|
||||
use gridcraft_core::{CellRef, RangeRef};
|
||||
@@ -176,6 +177,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(extra::specs());
|
||||
v.extend(macros::specs());
|
||||
v.extend(draw::specs());
|
||||
v.extend(wordart::specs());
|
||||
v.extend(spelling::specs());
|
||||
v.extend(solver::specs());
|
||||
v.extend(pivot::specs());
|
||||
|
||||
@@ -98,7 +98,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
validation
|
||||
),
|
||||
cmd!(query "data.validate", "Check Value Against Validation", [], None, "{cell?, input} → {ok, message?}", has_doc, validate_cmd),
|
||||
cmd!(noundo "data.circleInvalid", "Circle Invalid Data", ["Data", "Data Tools", "Data Validation"], None, "{} → invalid cells", has_doc, circle_invalid),
|
||||
cmd!(noundo "data.circleInvalid", "Circle Invalid Data", ["Data", "Data Tools", "Data Validation"], None, "{} → {invalid: [cell], truncated: bool (true when the 100,000-cell scan allowance ended before every range was checked)}", has_doc, circle_invalid),
|
||||
CommandSpec {
|
||||
shortcut_mac: Some("Cmd+Shift+K"),
|
||||
..cmd!(
|
||||
|
||||
@@ -184,7 +184,8 @@ pub(crate) fn grow_autofilter(sh: &mut Sheet, at: CellRef) {
|
||||
pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
|
||||
let Some(sh) = wb.sheet(sheet) else { return };
|
||||
let Some(af) = sh.autofilter.clone() else { return };
|
||||
let r = af.range;
|
||||
// An imported range can name rows past the grid; none of those exist to hide.
|
||||
let r = RangeRef::new(af.range.start, CellRef::new(af.range.end.row.min(gridcraft_core::MAX_ROWS - 1), af.range.end.col));
|
||||
let mut hide = Vec::new();
|
||||
// Precompute per-criterion top10 threshold and average once, not per row.
|
||||
let mut col_stats: std::collections::HashMap<usize, (Option<f64>, f64)> = std::collections::HashMap::new();
|
||||
|
||||
@@ -5,6 +5,9 @@ use super::{
|
||||
|
||||
// ---------------------------------------------------------------- tools
|
||||
|
||||
/// Most cells (rows times compared columns) one Remove Duplicates call reads.
|
||||
const MAX_COMPARED_CELLS: u64 = 50_000_000;
|
||||
|
||||
pub(super) fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (sheet, r) = data_target(s, p, "data.removeDuplicates")?;
|
||||
let d = s.doc()?;
|
||||
@@ -24,6 +27,10 @@ pub(super) fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if cols.is_empty() || cols.iter().any(|c| !(r.start.col..=r.end.col).contains(c)) {
|
||||
return Err(bad("data.removeDuplicates", "`columns` must name at least one column of the range"));
|
||||
}
|
||||
// Rows are compared one by one, so a sparse sheet's enclosing rectangle is bounded like a list.
|
||||
if r.count() > MAX_COMPARED_CELLS {
|
||||
return Err(bad("data.removeDuplicates", "the range is too large"));
|
||||
}
|
||||
let start = r.start.row + header as u32;
|
||||
let mut removed = 0;
|
||||
edit(s, |cx| {
|
||||
|
||||
@@ -337,11 +337,17 @@ pub(super) fn circle_invalid(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let sheet = d.wb.active_sheet();
|
||||
let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) };
|
||||
let mut bad_cells = Vec::new();
|
||||
// One scan allowance for the whole command: duplicate or overlapping ranges don't multiply it.
|
||||
let mut budget = 100_000usize;
|
||||
// Cells were left unchecked because the allowance ran out: said in the result.
|
||||
let mut truncated = false;
|
||||
for dv in &sh.validations {
|
||||
for r in &dv.ranges {
|
||||
let r = sh.used_range().and_then(|u| r.intersection(&u));
|
||||
let Some(r) = r else { continue };
|
||||
for c in r.iter().take(100_000) {
|
||||
truncated |= r.count() > budget as u64;
|
||||
for c in r.iter().take(budget) {
|
||||
budget -= 1;
|
||||
let text = sh.input_text(c);
|
||||
if !text.is_empty() && check_validation(&d.wb, sheet, c, &text).is_some() {
|
||||
bad_cells.push(c.a1());
|
||||
@@ -349,5 +355,5 @@ pub(super) fn circle_invalid(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"invalid": bad_cells}))
|
||||
Ok(json!({"invalid": bad_cells, "truncated": truncated}))
|
||||
}
|
||||
|
||||
@@ -199,7 +199,11 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let mut total = 0usize;
|
||||
for si in sheets {
|
||||
let cells: Vec<(CellRef, String)> =
|
||||
cx.wb.sheet(si).map(|sh| sh.cells.iter().map(|(c, _)| (c, sh.input_text(c))).collect()).unwrap_or_default();
|
||||
// A hidden formula's source is not searched: a count or error would reveal its text.
|
||||
cx.wb
|
||||
.sheet(si)
|
||||
.map(|sh| sh.cells.iter().filter(|(c, cell)| cell.formula.is_none() && cell.py.is_none() || !crate::display::formula_hidden(&cx.wb, sh, *c)).map(|(c, _)| (c, sh.input_text(c))).collect())
|
||||
.unwrap_or_default();
|
||||
for (c, text) in cells {
|
||||
if (!all && c != active) || selection.as_ref().is_some_and(|sel| !sel.contains(c)) {
|
||||
continue;
|
||||
|
||||
@@ -63,6 +63,11 @@ pub(super) fn selection_move(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let d = s.doc_mut()?;
|
||||
let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) };
|
||||
let sel = &mut d.selection;
|
||||
if extend && !jmp {
|
||||
// Shift+arrows move the edge the anchor doesn't hold, keeping merged cells whole.
|
||||
sel.extend_by(sheet, dr, dc);
|
||||
return Ok(json!({"selection": sel.a1(), "active": sel.active.a1()}));
|
||||
}
|
||||
// Extension moves the far corner, not the active cell.
|
||||
let from = if extend { sel.moving_end() } else { sel.active };
|
||||
// Skip hidden rows/columns and step over merged areas.
|
||||
@@ -77,23 +82,32 @@ pub(super) fn selection_move(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
jump(sheet, from, dr.signum(), dc.signum())
|
||||
} else {
|
||||
let mut t = from.offset_clamped(dr, dc);
|
||||
if !extend && let Some(m) = sheet.merge_at(from) {
|
||||
// Leaving a merged cell moves from its edge.
|
||||
if dr > 0 {
|
||||
t = CellRef::new(m.end.offset_clamped(dr, 0).row, t.col);
|
||||
} else if dc > 0 {
|
||||
t = CellRef::new(t.row, m.end.offset_clamped(0, dc).col);
|
||||
}
|
||||
}
|
||||
let mut guard = 0;
|
||||
while (sheet.is_row_hidden(t.row) || sheet.is_col_hidden(t.col)) && guard < 100_000 {
|
||||
let n = t.offset_clamped(dr.signum(), dc.signum());
|
||||
// Step each hidden axis along its own direction, so a diagonal exit from a merge
|
||||
// (Tab-run return column) keeps its column while skipping hidden rows.
|
||||
let mut n = CellRef::new(
|
||||
if sheet.is_row_hidden(t.row) { t.offset_clamped(dr.signum(), 0).row } else { t.row },
|
||||
if sheet.is_col_hidden(t.col) { t.offset_clamped(0, dc.signum()).col } else { t.col },
|
||||
);
|
||||
if n == t {
|
||||
// The hidden axis has no direction (a move along the other axis): walk as before.
|
||||
n = t.offset_clamped(dr.signum(), dc.signum());
|
||||
}
|
||||
if n == t {
|
||||
break;
|
||||
}
|
||||
t = n;
|
||||
guard += 1;
|
||||
}
|
||||
if !extend && let Some(m) = sheet.merge_at(from) {
|
||||
// Leaving a merged cell moves from its edge.
|
||||
if dr > 0 {
|
||||
t = CellRef::new(m.end.offset_clamped(dr, 0).row, from.col);
|
||||
} else if dc > 0 {
|
||||
t = CellRef::new(from.row, m.end.offset_clamped(0, dc).col);
|
||||
}
|
||||
}
|
||||
t
|
||||
};
|
||||
if let Some(m) = sheet.merge_at(to)
|
||||
@@ -140,7 +154,29 @@ pub(super) fn selection_next(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let fwd = bool_param(p, "forward").unwrap_or(true);
|
||||
let by_row = bool_param(p, "byRow").unwrap_or(false);
|
||||
let d = s.doc_mut()?;
|
||||
d.selection.cycle(fwd, by_row);
|
||||
let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) };
|
||||
let sel = &d.selection;
|
||||
if sel.is_one_merged_cell(sheet) {
|
||||
// A selection that is just one merged cell is one cell, as in Excel: Enter and Tab leave
|
||||
// it for the next cell instead of walking the cells it covers.
|
||||
let step = if fwd { 1 } else { -1 };
|
||||
let (dr, dc) = if by_row { (0, step) } else { (step, 0) };
|
||||
selection_move(s, &json!({"dr": dr, "dc": dc}))?;
|
||||
return Ok(json!({"active": s.doc()?.selection.active.a1()}));
|
||||
}
|
||||
// Inside a larger selection the cells a merged cell covers are skipped: it is visited once,
|
||||
// at its top-left cell.
|
||||
let area = sel.current();
|
||||
let cells =
|
||||
(u64::from(area.end.row.saturating_sub(area.start.row)) + 1).saturating_mul(u64::from(area.end.col.saturating_sub(area.start.col)) + 1);
|
||||
let mut next = sel.clone();
|
||||
for _ in 0..cells.min(1 << 20) {
|
||||
next.cycle(fwd, by_row);
|
||||
if sheet.merge_at(next.active).is_none_or(|m| m.start == next.active) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
d.selection = next;
|
||||
Ok(json!({"active": d.selection.active.a1()}))
|
||||
}
|
||||
|
||||
|
||||
@@ -891,6 +891,21 @@ mod tests {
|
||||
assert!((0.75..=1.15).contains(&ratio), "Arabic {} vs Latin {}", width(0), width(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autofit_of_many_separate_columns_fits_only_the_selected_ones() {
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
let long = "a much longer text than the others";
|
||||
// Columns A, C, E, G, I, K are selected (six runs), the ones between hold long text too.
|
||||
let row: Vec<_> = (0..11).map(|c| if c % 2 == 0 { json!("x") } else { json!(long) }).collect();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [row]})).unwrap();
|
||||
s.execute("home.autofitColumnWidth", json!({"cols": "A:A,C:C,E:E,G:G,I:I,K:K"})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let size = |c: u32| doc.wb.active().and_then(|sh| sh.cols.get(&c)).and_then(|e| e.size);
|
||||
assert!([0, 2, 4, 6, 8, 10].iter().all(|&c| size(c).is_some()));
|
||||
assert!([1, 3, 5, 7, 9].iter().all(|&c| size(c).is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_word_ligatures_are_wider_than_letters() {
|
||||
let em = |t: &str| approx_text_width(t, 72.0, false) / 96.0;
|
||||
|
||||
@@ -194,16 +194,35 @@ pub(super) fn autofit_cols(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let measure = s.text_width.clone();
|
||||
edit(s, |cx| {
|
||||
let Some(sh) = cx.wb.sheet(si) else { return Ok(Json::Null) };
|
||||
let mut widths = Vec::new();
|
||||
let mut measured: std::collections::HashMap<StyleId, std::collections::HashMap<String, f32>> = Default::default();
|
||||
let mut distinct = 0usize;
|
||||
let used = sh.used_range();
|
||||
for (a, b) in lines {
|
||||
for c in a..=b.min(a + 16383) {
|
||||
let Some(u) = used else { continue };
|
||||
let col = RangeRef::new(CellRef::new(u.start.row, c), CellRef::new(u.end.row, c));
|
||||
let mut max: f32 = 0.0;
|
||||
for (cell, _) in sh.cells.iter_range(col) {
|
||||
// Selected columns, then one pass over the populated cells (the store is row-oriented, so a
|
||||
// scan per column would revisit every row for each selected column, empty or not).
|
||||
let cols: std::collections::BTreeSet<u32> = lines.iter().flat_map(|&(a, b)| a..=b.min(a + 16383)).collect();
|
||||
let mut maxes: std::collections::BTreeMap<u32, f32> = Default::default();
|
||||
if let Some(u) = sh.used_range() {
|
||||
// One pass per run of adjacent selected columns, so columns between distant selections
|
||||
// (A and XFD) are never visited.
|
||||
let mut runs: Vec<(u32, u32)> = Vec::new();
|
||||
for &c in &cols {
|
||||
match runs.last_mut() {
|
||||
Some((_, end)) if *end + 1 == c => *end = c,
|
||||
_ => runs.push((c, c)),
|
||||
}
|
||||
}
|
||||
// Every run is a pass over all the stored rows: many runs (alternating columns) are one
|
||||
// pass over their whole span instead, the unselected columns skipped.
|
||||
if runs.len() > 4
|
||||
&& let (Some(&(first, _)), Some(&(_, last))) = (runs.first(), runs.last())
|
||||
{
|
||||
runs = vec![(first, last)];
|
||||
}
|
||||
for (first, last) in runs {
|
||||
let span = RangeRef::new(CellRef::new(u.start.row, first), CellRef::new(u.end.row, last));
|
||||
for (cell, _) in sh.cells.iter_range(span) {
|
||||
if !cols.contains(&cell.col) {
|
||||
continue;
|
||||
}
|
||||
if sh.merge_at(cell).is_some_and(|m| !m.is_single()) {
|
||||
continue;
|
||||
}
|
||||
@@ -226,14 +245,14 @@ pub(super) fn autofit_cols(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
None => approx_text_width(line, st.font.size, st.font.bold),
|
||||
};
|
||||
max = max.max(w + 6.0 + st.align.indent as f32 * 9.0 + sh.filter_button_space(cell));
|
||||
let w = w + 6.0 + st.align.indent as f32 * 9.0 + sh.filter_button_space(cell);
|
||||
let max = maxes.entry(cell.col).or_insert(0.0);
|
||||
*max = max.max(w);
|
||||
}
|
||||
}
|
||||
if max > 0.0 {
|
||||
widths.push((c, max.clamp(8.0, 1785.0)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let widths: Vec<(u32, f32)> = maxes.into_iter().filter(|&(_, m)| m > 0.0).map(|(c, m)| (c, m.clamp(8.0, 1785.0))).collect();
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
for (c, w) in widths {
|
||||
let e = sh.cols.entry(c).or_default();
|
||||
|
||||
@@ -1222,9 +1222,15 @@ fn shape_set_text(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = d.wb.active_sheet();
|
||||
let sh = d.wb.active().ok_or_else(|| bad("shape.setText", "no such text box"))?;
|
||||
let shape = sh.shapes.iter().find(|shape| shape.id == id).ok_or_else(|| bad("shape.setText", "no such text box"))?;
|
||||
if shape.kind != ShapeKind::TextBox {
|
||||
if !matches!(shape.kind, ShapeKind::TextBox | ShapeKind::WordArt) {
|
||||
return Err(bad("shape.setText", "the shape is not a text box"));
|
||||
}
|
||||
if shape.kind == ShapeKind::WordArt && text.is_empty() {
|
||||
return Err(bad("shape.setText", "WordArt text must not be empty"));
|
||||
}
|
||||
if shape.kind == ShapeKind::WordArt && text.chars().nth(super::wordart::MAX_CHARS).is_some() {
|
||||
return Err(bad("shape.setText", format!("`text` is longer than {} characters", super::wordart::MAX_CHARS)));
|
||||
}
|
||||
// Sheet protection locks drawing objects (there is no "edit objects" allowance yet).
|
||||
if sh.is_protected() {
|
||||
return Err(EngineError::Other(PROTECTED.into()));
|
||||
|
||||
@@ -32,8 +32,69 @@ use header_footer::draw_header_footer;
|
||||
pub use header_footer::{
|
||||
HF_FIELDS, HfContext, HfSection, codes_to_fields, expand_header_footer, fields_to_codes, hf_date_time, join_header_footer, split_header_footer,
|
||||
};
|
||||
/// Decoded pictures (bytes, width, height) keyed by (sheet, picture); `None` = undecodable.
|
||||
type PicCache = std::collections::HashMap<(usize, usize), Option<(Vec<u8>, u32, u32)>>;
|
||||
type DecodedPicture = (Vec<u8>, u32, u32);
|
||||
|
||||
/// What one PDF render shares across the pages an object spans.
|
||||
#[derive(Default)]
|
||||
struct PicCache {
|
||||
/// Decoded pictures (bytes, width, height) keyed by (sheet, picture); `None` = undecodable.
|
||||
decoded: std::collections::HashMap<(usize, usize), Option<DecodedPicture>>,
|
||||
/// Laid-out charts (with their [`chart_cost`]) keyed by (sheet, chart), rendered once however
|
||||
/// many pages they touch. Only charts that can still be drawn are kept.
|
||||
charts: std::collections::HashMap<(usize, usize), (Vec<gridcraft_chart::Prim>, usize)>,
|
||||
/// Charts too big for what was left of the budget: never drawn, so not rendered again.
|
||||
rejected_charts: std::collections::HashSet<(usize, usize)>,
|
||||
/// Bytes of picture pixels embedded so far, and chart cost units emitted so far. Each page an
|
||||
/// object touches gets its own copy in the PDF, so a workbook-controlled anchor over thousands
|
||||
/// of pages would multiply them without bound.
|
||||
image_bytes: usize,
|
||||
chart_prims: usize,
|
||||
}
|
||||
|
||||
impl PicCache {
|
||||
/// The chart's primitives for one more page, or `None` once they no longer fit
|
||||
/// [`MAX_CHART_PRIMS`]. `render` runs at most once per chart; a chart that doesn't fit is
|
||||
/// dropped at once and remembered, so its vectors are never retained.
|
||||
fn chart(&mut self, key: (usize, usize), render: impl FnOnce() -> Vec<gridcraft_chart::Prim>) -> Option<&[gridcraft_chart::Prim]> {
|
||||
if self.rejected_charts.contains(&key) {
|
||||
return None;
|
||||
}
|
||||
let (prims, cost) = match self.charts.remove(&key) {
|
||||
Some(cached) => cached,
|
||||
None => {
|
||||
let prims = render();
|
||||
let cost = chart_cost(&prims);
|
||||
(prims, cost)
|
||||
}
|
||||
};
|
||||
if self.chart_prims.saturating_add(cost) > MAX_CHART_PRIMS {
|
||||
self.rejected_charts.insert(key);
|
||||
return None;
|
||||
}
|
||||
self.chart_prims += cost;
|
||||
Some(&self.charts.entry(key).or_insert((prims, cost)).0)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a chart's drawing costs in the PDF: one unit per primitive plus one per path point (a
|
||||
/// smoothed line holds thousands of points, each written out) and per point of a wedge's outline.
|
||||
fn chart_cost(prims: &[gridcraft_chart::Prim]) -> usize {
|
||||
use gridcraft_chart::Prim;
|
||||
prims
|
||||
.iter()
|
||||
.map(|p| match p {
|
||||
Prim::Line { pts, .. } | Prim::Polygon { pts, .. } => pts.len().saturating_add(1),
|
||||
// At most 400 steps along each of the outer and inner arcs (see `draw_prims`).
|
||||
Prim::Wedge { .. } => 803,
|
||||
_ => 1,
|
||||
})
|
||||
.fold(0, usize::saturating_add)
|
||||
}
|
||||
|
||||
/// Most picture pixel bytes (RGBA, before PDF packing) one render embeds across all pages.
|
||||
const MAX_EMBEDDED_IMAGE_BYTES: usize = 512 * 1024 * 1024;
|
||||
/// Most chart cost units (primitives and path points, see [`chart_cost`]) one render draws across all pages.
|
||||
const MAX_CHART_PRIMS: usize = 4_000_000;
|
||||
|
||||
#[cfg(test)]
|
||||
use layout::bands;
|
||||
@@ -102,7 +163,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
|
||||
let mut printed_blocks = Vec::new();
|
||||
// Decoded pictures are reused across the pages their anchor covers: a floating picture
|
||||
// spanning 40 pages was decoded (and pushed into the PDF) 40 times (#2389).
|
||||
let mut pics = PicCache::new();
|
||||
let mut pics = PicCache::default();
|
||||
for lay in layouts {
|
||||
let Some(sh) = wb.sheet(lay.sheet) else { continue };
|
||||
let mut cf = if sh.cond_formats.is_empty() { None } else { Some(crate::cf::CfCache::new()) };
|
||||
|
||||
@@ -224,6 +224,12 @@ pub fn layout_sheet(wb: &Workbook, si: usize, opts: &PrintOptions, range_overrid
|
||||
Some(r) => trim_full(sh, r),
|
||||
None => print_range(sh).unwrap_or(RangeRef::cell(CellRef::new(0, 0))),
|
||||
};
|
||||
// A print area from a file or JSON can name coordinates past the grid; pagination walks every
|
||||
// line of the area (zero-height rows never fill a band), so it is held to the grid first.
|
||||
let area = RangeRef::new(
|
||||
CellRef::new(area.start.row.min(MAX_ROWS - 1), area.start.col.min(MAX_COLS - 1)),
|
||||
CellRef::new(area.end.row.min(MAX_ROWS - 1), area.end.col.min(MAX_COLS - 1)),
|
||||
);
|
||||
let title_rows = title_span(st.title_rows, MAX_ROWS);
|
||||
let title_cols = title_span(st.title_cols, MAX_COLS);
|
||||
let digits = format!("{}", area.end.row + 1).len() as f32;
|
||||
@@ -379,3 +385,19 @@ pub(super) fn segments(titles: Option<(u32, u32)>, band: (u32, u32)) -> Vec<(u32
|
||||
pub(super) fn seg_size(sh: &Sheet, seg: (u32, u32), rows: bool) -> f32 {
|
||||
if rows { (seg.0..=seg.1).map(|r| sh.row_height(r)).sum() } else { (seg.0..=seg.1).map(|c| sh.col_width(c)).sum() }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_print_area_past_the_grid_is_held_to_the_grid() {
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
sh.default_row_height = 0.0;
|
||||
sh.print.print_area = Some(RangeRef { start: CellRef::new(0, 0), end: CellRef::new(u32::MAX, u32::MAX) });
|
||||
let lay = layout_sheet(&wb, 0, &PrintOptions::default(), None).unwrap();
|
||||
assert_eq!((lay.area.end.row, lay.area.end.col), (MAX_ROWS - 1, MAX_COLS - 1));
|
||||
assert!(lay.pages.len() <= MAX_PAGES);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,9 +88,14 @@ pub(super) fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet,
|
||||
let (x, y, w, h) = place(r);
|
||||
// About 150 dpi is plenty for print.
|
||||
let (mw, mh) = (((w / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32, ((h / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32);
|
||||
let decoded = pics.entry((si, imi)).or_insert_with(|| decode_image(&im.data, mw, mh));
|
||||
let decoded = pics.decoded.entry((si, imi)).or_insert_with(|| decode_image(&im.data, mw, mh));
|
||||
if let Some((px, pw, ph)) = decoded.as_ref() {
|
||||
pg.image_rgba(Rect::new(x, y, w, h), px, [*pw, *ph]);
|
||||
// Every page the picture touches embeds its own copy: past the budget it is left out.
|
||||
let bytes = px.len();
|
||||
if pics.image_bytes.saturating_add(bytes) <= super::MAX_EMBEDDED_IMAGE_BYTES {
|
||||
pics.image_bytes += bytes;
|
||||
pg.image_rgba(Rect::new(x, y, w, h), px, [*pw, *ph]);
|
||||
}
|
||||
}
|
||||
}
|
||||
for shp in &sh.shapes {
|
||||
@@ -120,15 +125,20 @@ pub(super) fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet,
|
||||
}
|
||||
draw_shape(pg, wb, shp, b, (x, y, w, h));
|
||||
}
|
||||
for ch in &sh.charts {
|
||||
for (chi, ch) in sh.charts.iter().enumerate() {
|
||||
let r = anchor_rect(sh, &ch.anchor);
|
||||
if !visible(r) {
|
||||
continue;
|
||||
}
|
||||
let (x, y, _, _) = place(r);
|
||||
let data = gridcraft_chart::resolve(wb, si, ch);
|
||||
let prims = gridcraft_chart::render(ch, &data, r.2 as f32, r.3 as f32, &PdfMeasure);
|
||||
draw_prims(pg, &prims, Xf { ox: x, oy: y, k: b.k });
|
||||
// As for pictures, each page gets its own copy of the drawing commands.
|
||||
let prims = pics.chart((si, chi), || {
|
||||
let data = gridcraft_chart::resolve(wb, si, ch);
|
||||
gridcraft_chart::render(ch, &data, r.2 as f32, r.3 as f32, &PdfMeasure)
|
||||
});
|
||||
if let Some(prims) = prims {
|
||||
draw_prims(pg, prims, Xf { ox: x, oy: y, k: b.k });
|
||||
}
|
||||
}
|
||||
pg.restore();
|
||||
}
|
||||
@@ -203,6 +213,21 @@ fn draw_shape(pg: &mut Page, wb: &Workbook, shp: &gridcraft_model::Shape, b: &Bl
|
||||
ShapeTextAlign::Center => x + (w - lw) / 2.0,
|
||||
ShapeTextAlign::Right => x + w - 2.0 * b.k - lw,
|
||||
};
|
||||
// WordArt's drop shadow: the line again, offset, in the shadow colour (under the text).
|
||||
if let Some(sc) = fm.text_shadow.and_then(|c| rgb_of(c, wb)) {
|
||||
let off = (size * 0.06).max(0.5);
|
||||
pg.text(lx + off, yy + off, size, font, sc, l);
|
||||
}
|
||||
// WordArt's text outline, as on screen: the line in the outline colour, offset in eight
|
||||
// directions by half the outline width, under the filled text.
|
||||
if let Some((c, ow)) = fm.text_outline
|
||||
&& let Some(oc) = rgb_of(c, wb)
|
||||
{
|
||||
let d = (ow * b.scale / 2.0).clamp(0.25, 20.0);
|
||||
for (dx, dy) in [(-1.0, -1.0), (0.0, -1.0), (1.0, -1.0), (-1.0, 0.0), (1.0, 0.0), (-1.0, 1.0), (0.0, 1.0), (1.0, 1.0)] {
|
||||
pg.text(lx + dx * d, yy + dy * d, size, font, oc, l);
|
||||
}
|
||||
}
|
||||
pg.text(lx, yy, size, font, color, l);
|
||||
yy += size * 1.2;
|
||||
}
|
||||
|
||||
@@ -938,3 +938,27 @@ fn equations_print_with_the_symbol_font() {
|
||||
// A glyph no standard font has is a [?] box.
|
||||
assert!(contains(&pdf, "(?) Tj"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chart_budget_counts_path_points_and_drops_charts_that_do_not_fit() {
|
||||
use gridcraft_chart::Prim;
|
||||
let line = |n: usize| Prim::Line { pts: vec![[0.0, 0.0]; n], color: [0, 0, 0, 255], width: 1.0, dash: false };
|
||||
// A smoothed line is its points, not one unit.
|
||||
assert_eq!(chart_cost(&[line(4000), Prim::Circle { cx: 0.0, cy: 0.0, r: 1.0, fill: None, stroke: None }]), 4002);
|
||||
// Room for exactly two copies of a 1,000-point chart.
|
||||
let mut pics = PicCache { chart_prims: MAX_CHART_PRIMS - 2002, ..Default::default() };
|
||||
let mut renders = 0;
|
||||
for expect in [true, true, false, false] {
|
||||
let got = pics.chart((0, 0), || {
|
||||
renders += 1;
|
||||
vec![line(1000)]
|
||||
});
|
||||
assert_eq!(got.is_some(), expect);
|
||||
}
|
||||
assert_eq!(renders, 1, "a rejected chart is not rendered again");
|
||||
assert!(pics.charts.is_empty(), "and its primitives are not kept");
|
||||
// A chart too big for what is left is dropped without being cached at all.
|
||||
let mut pics = PicCache { chart_prims: MAX_CHART_PRIMS - 10, ..Default::default() };
|
||||
assert!(pics.chart((0, 1), || vec![line(1000)]).is_none());
|
||||
assert!(pics.charts.is_empty() && pics.rejected_charts.contains(&(0, 1)));
|
||||
}
|
||||
|
||||
@@ -83,6 +83,9 @@ pub(crate) fn criterion_matches(v: &Value, text: &str, crit: &Value) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Most (list row, criteria row) pairs one Advanced Filter tests.
|
||||
const MAX_FILTER_TESTS: u64 = 50_000_000;
|
||||
|
||||
pub(super) fn advanced_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
const CMD: &str = "data.advancedFilter";
|
||||
let d = s.doc()?;
|
||||
@@ -133,6 +136,10 @@ pub(super) fn advanced_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
rows.push(conds);
|
||||
}
|
||||
}
|
||||
// Each list row is tried against every criteria row, so bound their product too.
|
||||
if u64::from(list.height()) * rows.len() as u64 > MAX_FILTER_TESTS {
|
||||
return Err(bad(CMD, "the list and criteria ranges together are too large"));
|
||||
}
|
||||
let matches_row = |row: u32| -> bool {
|
||||
if rows.is_empty() {
|
||||
return true;
|
||||
|
||||
@@ -298,6 +298,15 @@ fn consolidate_by_position_and_label() {
|
||||
assert!(s.execute("data.consolidate", json!({"sources": ["A1"], "function": "median"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_filter_refuses_a_list_times_criteria_product_over_the_work_limit() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Name"], ["A"]]})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "H1", "values": [["Name"], ["A"]]})).unwrap();
|
||||
// A sparse million-row list against 9,999 blank criteria rows would be ~10 billion tests.
|
||||
assert!(s.execute("data.advancedFilter", json!({"range": "A2:A1048576", "criteria": "H1:H10000"})).is_err());
|
||||
}
|
||||
|
||||
/// A hundred thousand positional rows take one key lookup each; a tall source and a wide one
|
||||
/// whose keys would meet in a table of 160 million cells are refused before it is built.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Insert › Text › WordArt: decorative text as a floating shape (`ShapeKind::WordArt`) built on
|
||||
//! the text-box machinery (anchor, move/resize/delete, undo, XLSX shape text body, printing).
|
||||
//! The style palette below is original to GridCraft.
|
||||
|
||||
use gridcraft_model::{Anchor, Color, Shape, ShapeFormat, ShapeKind, ShapeTextAlign, ShapeTextAnchor};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::{
|
||||
CommandSpec, EngineError, PROTECTED, Result, Session, anchor_mode_param, bad, bool_param, cell_param, cmd, edit, f64_param, has_doc, ok,
|
||||
u32_param,
|
||||
};
|
||||
|
||||
/// One entry of the style gallery.
|
||||
pub struct WordArtStyle {
|
||||
pub name: &'static str,
|
||||
pub fill: Color,
|
||||
pub outline: Option<(Color, f32)>,
|
||||
pub shadow: Option<Color>,
|
||||
pub bold: bool,
|
||||
pub italic: bool,
|
||||
/// Font size in points.
|
||||
pub size: f32,
|
||||
}
|
||||
|
||||
const fn rgb(v: u32) -> Color {
|
||||
Color::Rgb(v)
|
||||
}
|
||||
|
||||
/// GridCraft's own WordArt presets. Theme colours where it makes sense (they follow the theme),
|
||||
/// fixed colours for the rest.
|
||||
pub const STYLES: [WordArtStyle; 10] = [
|
||||
WordArtStyle { name: "Classic", fill: Color::Theme(4, 0), outline: None, shadow: None, bold: true, italic: false, size: 36.0 },
|
||||
WordArtStyle { name: "Midnight", fill: rgb(0x1B2A49), outline: None, shadow: Some(rgb(0x8A94A6)), bold: true, italic: false, size: 40.0 },
|
||||
WordArtStyle { name: "Sunrise", fill: rgb(0xF2A33A), outline: Some((rgb(0xB5451B), 1.5)), shadow: None, bold: true, italic: false, size: 40.0 },
|
||||
WordArtStyle {
|
||||
name: "Lagoon",
|
||||
fill: rgb(0x1F9E9A),
|
||||
outline: Some((rgb(0x0B4F5C), 1.0)),
|
||||
shadow: Some(rgb(0x9CC9C7)),
|
||||
bold: true,
|
||||
italic: true,
|
||||
size: 36.0,
|
||||
},
|
||||
WordArtStyle { name: "Ember", fill: rgb(0xD64527), outline: None, shadow: Some(rgb(0x5A1A0E)), bold: true, italic: false, size: 44.0 },
|
||||
WordArtStyle {
|
||||
name: "Mint Outline",
|
||||
fill: rgb(0xFFFFFF),
|
||||
outline: Some((rgb(0x2E9B6A), 2.0)),
|
||||
shadow: None,
|
||||
bold: true,
|
||||
italic: false,
|
||||
size: 40.0,
|
||||
},
|
||||
WordArtStyle { name: "Ink Stamp", fill: rgb(0x2F3A8F), outline: Some((rgb(0x2F3A8F), 0.5)), shadow: None, bold: true, italic: false, size: 32.0 },
|
||||
WordArtStyle { name: "Orchid", fill: rgb(0x8E4FB5), outline: None, shadow: Some(rgb(0xD9C2E8)), bold: false, italic: true, size: 40.0 },
|
||||
WordArtStyle {
|
||||
name: "Graphite",
|
||||
fill: rgb(0x4A4F57),
|
||||
outline: Some((rgb(0xFFFFFF), 1.0)),
|
||||
shadow: Some(rgb(0x9AA0A8)),
|
||||
bold: true,
|
||||
italic: false,
|
||||
size: 36.0,
|
||||
},
|
||||
WordArtStyle {
|
||||
name: "Lime Pop",
|
||||
fill: rgb(0xA6D61C),
|
||||
outline: Some((rgb(0x3F5A00), 1.5)),
|
||||
shadow: Some(rgb(0x6E8B2C)),
|
||||
bold: true,
|
||||
italic: true,
|
||||
size: 40.0,
|
||||
},
|
||||
];
|
||||
|
||||
/// The text of a WordArt inserted without `text`.
|
||||
pub const DEFAULT_TEXT: &str = "Your text here";
|
||||
/// Longest WordArt text (characters).
|
||||
pub const MAX_CHARS: usize = 1000;
|
||||
const MAX_POINTS: f64 = 400.0;
|
||||
const MAX_SIDE: f64 = 4000.0;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"insert.wordArt",
|
||||
"WordArt",
|
||||
["Insert", "Text"],
|
||||
None,
|
||||
"{style?: preset name or 0-based index (Classic, Midnight, Sunrise, Lagoon, Ember, Mint Outline, Ink Stamp, Orchid, Graphite, Lime Pop), text?, at?, width?, height?, fontSize?, bold?, italic?, fill?: hex, outline?: hex|false, outlineWidth?, shadow?: hex|bool}",
|
||||
has_doc,
|
||||
insert_word_art
|
||||
),
|
||||
cmd!(
|
||||
"wordArt.set",
|
||||
"Format WordArt",
|
||||
[],
|
||||
None,
|
||||
"{id: WordArt id, style?, text?, fontSize?, bold?, italic?, fill?: hex, outline?: hex|false, outlineWidth?, shadow?: hex|bool}",
|
||||
has_doc,
|
||||
set_word_art
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn style_param(command: &str, p: &Json) -> Result<Option<&'static WordArtStyle>> {
|
||||
match p.get("style") {
|
||||
None | Some(Json::Null) => Ok(None),
|
||||
Some(Json::Number(n)) => n
|
||||
.as_u64()
|
||||
.and_then(|i| STYLES.get(usize::try_from(i).ok()?))
|
||||
.map(Some)
|
||||
.ok_or_else(|| bad(command, format!("`style` index must be 0..{}", STYLES.len() - 1))),
|
||||
Some(Json::String(name)) => STYLES
|
||||
.iter()
|
||||
.find(|s| s.name.eq_ignore_ascii_case(name.trim()))
|
||||
.map(Some)
|
||||
.ok_or_else(|| bad(command, format!("unknown WordArt style `{name}`"))),
|
||||
Some(_) => Err(bad(command, "`style` must be a preset name or index")),
|
||||
}
|
||||
}
|
||||
|
||||
fn text_param(command: &str, p: &Json) -> Result<Option<String>> {
|
||||
match p.get("text") {
|
||||
None | Some(Json::Null) => Ok(None),
|
||||
Some(Json::String(t)) if t.is_empty() => Err(bad(command, "`text` must not be empty")),
|
||||
Some(Json::String(t)) if t.chars().nth(MAX_CHARS).is_some() => Err(bad(command, format!("`text` is longer than {MAX_CHARS} characters"))),
|
||||
Some(Json::String(t)) => Ok(Some(t.clone())),
|
||||
Some(_) => Err(bad(command, "`text` must be a string")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a preset, then explicit overrides, to a shape's format and fill.
|
||||
fn apply(command: &str, shape: &mut Shape, style: Option<&WordArtStyle>, p: &Json) -> Result<()> {
|
||||
if let Some(st) = style {
|
||||
shape.format.text_color = Some(st.fill);
|
||||
shape.format.text_outline = st.outline;
|
||||
shape.format.text_shadow = st.shadow;
|
||||
shape.format.bold = st.bold;
|
||||
shape.format.italic = st.italic;
|
||||
shape.format.font_size = Some(st.size);
|
||||
}
|
||||
let f = &mut shape.format;
|
||||
if let Some(c) = p.get("fill").filter(|v| !v.is_null()) {
|
||||
f.text_color = Some(super::format::color_param(Some(c)).ok_or_else(|| bad(command, "`fill` must be a hex colour"))?);
|
||||
}
|
||||
match p.get("outline") {
|
||||
None | Some(Json::Null) => {}
|
||||
Some(Json::Bool(false)) => f.text_outline = None,
|
||||
Some(v) => {
|
||||
let c = super::format::color_param(Some(v)).ok_or_else(|| bad(command, "`outline` must be a hex colour or false"))?;
|
||||
let w = f64_param(p, "outlineWidth").map_or(f.text_outline.map_or(1.5, |o| o.1), |w| w.clamp(0.25, 20.0) as f32);
|
||||
f.text_outline = Some((c, w));
|
||||
}
|
||||
}
|
||||
if let (Some(w), Some((c, _))) = (f64_param(p, "outlineWidth"), f.text_outline) {
|
||||
f.text_outline = Some((c, w.clamp(0.25, 20.0) as f32));
|
||||
}
|
||||
match p.get("shadow") {
|
||||
None | Some(Json::Null) => {}
|
||||
Some(Json::Bool(false)) => f.text_shadow = None,
|
||||
Some(Json::Bool(true)) => f.text_shadow = Some(f.text_shadow.unwrap_or(rgb(0x808080))),
|
||||
Some(v) => {
|
||||
f.text_shadow = Some(super::format::color_param(Some(v)).ok_or_else(|| bad(command, "`shadow` must be a hex colour or a boolean"))?)
|
||||
}
|
||||
}
|
||||
if let Some(b) = bool_param(p, "bold") {
|
||||
f.bold = b;
|
||||
}
|
||||
if let Some(b) = bool_param(p, "italic") {
|
||||
f.italic = b;
|
||||
}
|
||||
if let Some(sz) = f64_param(p, "fontSize") {
|
||||
f.font_size = Some(sz.clamp(1.0, MAX_POINTS) as f32);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn insert_word_art(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let style = style_param("insert.wordArt", p)?.unwrap_or(&STYLES[0]);
|
||||
let text = text_param("insert.wordArt", p)?.unwrap_or_else(|| DEFAULT_TEXT.to_string());
|
||||
let d = s.doc()?;
|
||||
if d.wb.active().is_some_and(|sh| sh.is_protected()) {
|
||||
return Err(EngineError::Other(PROTECTED.into()));
|
||||
}
|
||||
let id = d.wb.next_object_id();
|
||||
let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
|
||||
let mut shape = Shape {
|
||||
id,
|
||||
kind: ShapeKind::WordArt,
|
||||
anchor: Anchor { cell: at, dx: 0.0, dy: 0.0, width: 0.0, height: 0.0, mode: anchor_mode_param(p).unwrap_or_default() },
|
||||
fill: Color::Auto,
|
||||
line: Color::Auto,
|
||||
text: text.clone(),
|
||||
format: ShapeFormat { no_line: true, align: Some(ShapeTextAlign::Center), anchor: Some(ShapeTextAnchor::Middle), ..Default::default() },
|
||||
math: Default::default(),
|
||||
};
|
||||
apply("insert.wordArt", &mut shape, Some(style), p)?;
|
||||
// Default box: the longest line at the font size (about 0.62 em per character) plus padding.
|
||||
let pt = f64::from(shape.format.font_size.unwrap_or(36.0));
|
||||
let longest = text.split('\n').map(|l| l.chars().count()).max().unwrap_or(1) as f64;
|
||||
let lines = text.split('\n').count() as f64;
|
||||
let width = f64_param(p, "width").unwrap_or(longest * pt * 0.62 * 96.0 / 72.0 + 24.0).clamp(8.0, MAX_SIDE);
|
||||
let height = f64_param(p, "height").unwrap_or(lines * pt * 1.35 * 96.0 / 72.0 + 12.0).clamp(8.0, MAX_SIDE);
|
||||
shape.anchor.width = width as f32;
|
||||
shape.anchor.height = height as f32;
|
||||
let sheet = d.wb.active_sheet();
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?.shapes.push(shape);
|
||||
Ok(json!({"shape": id}))
|
||||
})
|
||||
}
|
||||
|
||||
fn set_word_art(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let id = u32_param(p, "id").ok_or_else(|| bad("wordArt.set", "`id` must be a WordArt id"))?;
|
||||
let style = style_param("wordArt.set", p)?;
|
||||
let text = text_param("wordArt.set", p)?;
|
||||
let d = s.doc()?;
|
||||
let sheet = d.wb.active_sheet();
|
||||
let sh = d.wb.active().ok_or_else(|| bad("wordArt.set", "no such WordArt"))?;
|
||||
let current = sh.shapes.iter().find(|x| x.id == id).ok_or_else(|| bad("wordArt.set", "no such WordArt"))?;
|
||||
if current.kind != ShapeKind::WordArt {
|
||||
return Err(bad("wordArt.set", "the shape is not WordArt"));
|
||||
}
|
||||
if sh.is_protected() {
|
||||
return Err(EngineError::Other(PROTECTED.into()));
|
||||
}
|
||||
let mut updated = current.clone();
|
||||
apply("wordArt.set", &mut updated, style, p)?;
|
||||
if let Some(t) = text {
|
||||
updated.text = t;
|
||||
}
|
||||
if &updated == current {
|
||||
return ok();
|
||||
}
|
||||
edit(s, |cx| {
|
||||
let shape = cx.sheet_mut(sheet)?.shapes.iter_mut().find(|x| x.id == id).ok_or_else(|| bad("wordArt.set", "no such WordArt"))?;
|
||||
*shape = updated;
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
+106
-21
@@ -3,7 +3,7 @@
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef};
|
||||
use gridcraft_model::{Sheet, Workbook};
|
||||
use gridcraft_model::{LineIndex, Sheet, Workbook};
|
||||
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
@@ -611,6 +611,24 @@ pub(crate) fn esc(s: &str) -> String {
|
||||
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
|
||||
/// [`esc`], or `None` once the escaped text would pass `limit` bytes (nothing larger is built).
|
||||
fn esc_within(s: &str, limit: usize) -> Option<String> {
|
||||
let mut out = String::with_capacity(s.len().min(limit));
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
c => out.push(c),
|
||||
}
|
||||
if out.len() > limit {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn html_cell_css(wb: &Workbook, sh: &Sheet, c: CellRef, dir: CellDir) -> String {
|
||||
let st = wb.styles.get(sh.style_id(c));
|
||||
let mut css = String::new();
|
||||
@@ -686,6 +704,30 @@ fn table_dir(sh: &Sheet) -> &'static str {
|
||||
const CLIPBOARD_HTML_MAX_BYTES: usize = 4 * 1024 * 1024;
|
||||
const CLIPBOARD_HTML_MAX_CELLS: u64 = 10_000;
|
||||
|
||||
/// Most cells the merge index holds: one entry per merged cell inside the exported range.
|
||||
const MERGE_INDEX_MAX_ENTRIES: usize = 1_000_000;
|
||||
|
||||
/// The merge covering each cell of `rect`, as `Sheet::merge_at` would find it (the first in the
|
||||
/// sheet's list), built once so a table lookup is one probe instead of a scan of every merge.
|
||||
/// `None` when overlapping merges would cost more than a few visits per cell of `rect`, or merged
|
||||
/// cells fill more than [`MERGE_INDEX_MAX_ENTRIES`]: a hostile file's merge list can't multiply the
|
||||
/// work or the memory of a bounded export.
|
||||
fn merge_index(sh: &Sheet, rect: RangeRef) -> Option<std::collections::HashMap<CellRef, RangeRef>> {
|
||||
let mut map = std::collections::HashMap::new();
|
||||
let mut left = rect.count().saturating_mul(4);
|
||||
for m in &sh.merges {
|
||||
let Some(part) = m.intersection(&rect) else { continue };
|
||||
left = left.checked_sub(part.count())?;
|
||||
for c in part.iter() {
|
||||
map.entry(c).or_insert(*m);
|
||||
}
|
||||
if map.len() > MERGE_INDEX_MAX_ENTRIES {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(map)
|
||||
}
|
||||
|
||||
/// An HTML fragment for the same range as `plain_text`, previously produced by
|
||||
/// `display::range_text`. Large copies keep plain text only; never return a partial table.
|
||||
pub(crate) fn range_html(wb: &Workbook, sheet: usize, r: RangeRef, plain_text: &str) -> Option<String> {
|
||||
@@ -694,6 +736,7 @@ pub(crate) fn range_html(wb: &Workbook, sheet: usize, r: RangeRef, plain_text: &
|
||||
return None;
|
||||
}
|
||||
let sh = wb.sheet(sheet)?;
|
||||
let merges = merge_index(sh, r)?;
|
||||
let mut out = ClipboardHtml(String::new());
|
||||
write!(out, "<table{} style=\"border-collapse:collapse;font-family:Calibri,Carlito,Arial,sans-serif;font-size:11pt\">", table_dir(sh)).ok()?;
|
||||
for row in r.start.row..=r.end.row {
|
||||
@@ -703,7 +746,7 @@ pub(crate) fn range_html(wb: &Workbook, sheet: usize, r: RangeRef, plain_text: &
|
||||
out.write_str("<tr>").ok()?;
|
||||
for col in r.start.col..=r.end.col {
|
||||
let c = CellRef::new(row, col);
|
||||
let span = if let Some(merged) = sh.merges.iter().find(|m| m.contains(c)).and_then(|m| m.intersection(&r)) {
|
||||
let span = if let Some(merged) = merges.get(&c).and_then(|m| m.intersection(&r)) {
|
||||
let first_row = (merged.start.row..=merged.end.row).find(|&row| !sh.is_row_hidden(row))?;
|
||||
if c != CellRef::new(first_row, merged.start.col) {
|
||||
continue;
|
||||
@@ -766,6 +809,14 @@ const HTML_EXPORT_MAX_ROWS: u32 = 100_001;
|
||||
/// tall, wide one) would otherwise be gigabytes of `<td></td>` before finishing (#2387).
|
||||
const HTML_EXPORT_MAX_CELLS: u64 = 4_000_000;
|
||||
|
||||
/// Most bytes of one web page. Every cell may hold long text (shared across many cells in the
|
||||
/// file), so the cell count alone doesn't bound the page; past this the export fails instead.
|
||||
const HTML_EXPORT_MAX_BYTES: usize = 256 * 1024 * 1024;
|
||||
|
||||
fn html_too_big() -> EngineError {
|
||||
EngineError::Other(format!("the web page would be over {} MiB. Save as .xlsx or CSV instead.", HTML_EXPORT_MAX_BYTES / (1024 * 1024)))
|
||||
}
|
||||
|
||||
/// A sheet as a standalone HTML table (Save as Web Page). A sheet with pictures gets the sheet's
|
||||
/// own column widths and row heights so each picture and chart (inline SVG) can sit at its anchor.
|
||||
pub fn to_html(wb: &Workbook, sheet: usize) -> Result<String> {
|
||||
@@ -808,6 +859,24 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result<String> {
|
||||
r.width()
|
||||
)));
|
||||
}
|
||||
let merges = merge_index(sh, r)
|
||||
.ok_or_else(|| EngineError::Other("the sheet's merged cells overlap too much, or cover too many cells, for a web page.".into()))?;
|
||||
// Visible rows and columns of the table before each line, so a merge's span is counted
|
||||
// in constant time whatever the file says its size is.
|
||||
let visible_before = |hidden: &dyn Fn(u32) -> bool, from: u32, to: u32| {
|
||||
let mut v = Vec::with_capacity((to - from) as usize + 2);
|
||||
v.push(0u32);
|
||||
for line in from..=to {
|
||||
v.push(v.last().copied().unwrap_or(0) + u32::from(!hidden(line)));
|
||||
}
|
||||
v
|
||||
};
|
||||
let rows_before = visible_before(&|y| sh.is_row_hidden(y), r.start.row, r.end.row);
|
||||
let cols_before = visible_before(&|x| sh.is_col_hidden(x), r.start.col, r.end.col);
|
||||
let visible = |before: &[u32], from: u32, a: u32, b: u32| {
|
||||
let at = |line: u32| before.get((line - from) as usize).copied().unwrap_or(0);
|
||||
at(b + 1) - at(a)
|
||||
};
|
||||
if fixed {
|
||||
out.push_str("<colgroup>");
|
||||
for col in (r.start.col..=r.end.col).filter(|&c| !sh.is_col_hidden(c)) {
|
||||
@@ -826,27 +895,31 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result<String> {
|
||||
}
|
||||
for col in r.start.col..=r.end.col {
|
||||
let c = CellRef::new(row, col);
|
||||
if sh.merges.iter().any(|m| m.contains(c) && m.start != c) || (fixed && sh.is_col_hidden(col)) {
|
||||
let merge = merges.get(&c);
|
||||
if merge.is_some_and(|m| m.start != c) || (fixed && sh.is_col_hidden(col)) {
|
||||
continue;
|
||||
}
|
||||
let text = crate::display::cell_text(wb, sh, c);
|
||||
let dir = CellDir::of(sh, c, &text);
|
||||
let css = html_cell_css(wb, sh, c, dir);
|
||||
let span = sh
|
||||
.merges
|
||||
.iter()
|
||||
.find(|m| m.start == c)
|
||||
let span = merge
|
||||
.map(|m| {
|
||||
if fixed {
|
||||
let rows = (m.start.row..=m.end.row).filter(|&y| !sh.is_row_hidden(y)).count();
|
||||
let cols = (m.start.col..=m.end.col).filter(|&x| !sh.is_col_hidden(x)).count();
|
||||
// Spans count the table's visible lines: the merge is clipped to the table.
|
||||
let rows = visible(&rows_before, r.start.row, m.start.row, m.end.row.min(r.end.row));
|
||||
let cols = visible(&cols_before, r.start.col, m.start.col, m.end.col.min(r.end.col));
|
||||
format!(" rowspan=\"{rows}\" colspan=\"{cols}\"")
|
||||
} else {
|
||||
format!(" rowspan=\"{}\" colspan=\"{}\"", m.height(), m.width())
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let _ = write!(out, "<td style=\"{css}\"{}{span}>{}</td>", dir.attr(), esc(&text));
|
||||
// The text is escaped only as far as the page still has room for it.
|
||||
let Some(escaped) = esc_within(&text, HTML_EXPORT_MAX_BYTES.saturating_sub(out.len())) else { return Err(html_too_big()) };
|
||||
let _ = write!(out, "<td style=\"{css}\"{}{span}>{escaped}</td>", dir.attr());
|
||||
if out.len() > HTML_EXPORT_MAX_BYTES {
|
||||
return Err(html_too_big());
|
||||
}
|
||||
}
|
||||
out.push_str("</tr>");
|
||||
}
|
||||
@@ -854,36 +927,48 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result<String> {
|
||||
out.push_str("</table>");
|
||||
if fixed {
|
||||
// Sheet units are CSS pixels; the table starts at the used range, not at A1.
|
||||
let (left, top) = used.map_or((0.0, 0.0), |r| (sh.col_left(r.start.col), sh.row_top(r.start.row)));
|
||||
// Indexed positions: each anchor would otherwise sum every custom row and column before it.
|
||||
let ri = LineIndex::new(&sh.rows, sh.default_row_height, gridcraft_core::MAX_ROWS);
|
||||
let ci = LineIndex::new(&sh.cols, sh.default_col_width, gridcraft_core::MAX_COLS);
|
||||
let (left, top) = used.map_or((0.0, 0.0), |r| (ci.start(r.start.col), ri.start(r.start.row)));
|
||||
let place = |a: &gridcraft_model::Anchor| {
|
||||
format!(
|
||||
"position:absolute;left:{}px;top:{}px;width:{}px;height:{}px",
|
||||
sh.col_left(a.cell.col) - left + f64::from(a.dx),
|
||||
sh.row_top(a.cell.row) - top + f64::from(a.dy),
|
||||
ci.start(a.cell.col) - left + f64::from(a.dx),
|
||||
ri.start(a.cell.row) - top + f64::from(a.dy),
|
||||
a.width.max(0.0),
|
||||
a.height.max(0.0)
|
||||
)
|
||||
};
|
||||
for im in &sh.images {
|
||||
let _ = write!(
|
||||
out,
|
||||
"<img src=\"data:{};base64,{}\" alt=\"{}\" style=\"{}\">",
|
||||
esc(&im.mime),
|
||||
base64_encode(&im.data),
|
||||
esc(&im.alt),
|
||||
place(&im.anchor)
|
||||
);
|
||||
// The base64 text is a third longer than the picture: refused before it is built.
|
||||
if out.len().saturating_add(im.data.len().div_ceil(3).saturating_mul(4)) > HTML_EXPORT_MAX_BYTES {
|
||||
return Err(html_too_big());
|
||||
}
|
||||
// The attributes are escaped only as far as the page has room for them.
|
||||
let room = HTML_EXPORT_MAX_BYTES.saturating_sub(out.len());
|
||||
let (Some(mime), Some(alt)) = (esc_within(&im.mime, room), esc_within(&im.alt, room)) else { return Err(html_too_big()) };
|
||||
let _ = write!(out, "<img src=\"data:{mime};base64,{}\" alt=\"{alt}\" style=\"{}\">", base64_encode(&im.data), place(&im.anchor));
|
||||
if out.len() > HTML_EXPORT_MAX_BYTES {
|
||||
return Err(html_too_big());
|
||||
}
|
||||
}
|
||||
let mut svg_budget = crate::chart_svg::MAX_PAGE_SVG_BYTES;
|
||||
for ch in &sh.charts {
|
||||
if let Some(svg) = crate::chart_svg::chart_svg(wb, sheet, ch, &place(&ch.anchor), svg_budget)? {
|
||||
svg_budget -= svg.len();
|
||||
out.push_str(&svg);
|
||||
if out.len() > HTML_EXPORT_MAX_BYTES {
|
||||
return Err(html_too_big());
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str("</div>");
|
||||
}
|
||||
out.push_str("</body></html>");
|
||||
if out.len() > HTML_EXPORT_MAX_BYTES {
|
||||
return Err(html_too_big());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
|
||||
+37
-301
@@ -1,156 +1,57 @@
|
||||
//! SYLK (`.slk`) import: the symbolic link text format older spreadsheets and some legacy systems
|
||||
//! export. One record per line, fields separated by `;` (`;;` is a literal semicolon):
|
||||
//!
|
||||
//! - `ID` starts the file; `E` ends it.
|
||||
//! - `C;Y<row>;X<col>;K<value>;E<formula>` is a cell. `Y` and `X` may be left out to stay on the
|
||||
//! current row or column. `K` is a number, `"text"`, `TRUE`/`FALSE` or an error; `E` is a
|
||||
//! formula in R1C1 notation; `S;R<row>;C<col>` shares the formula of another cell.
|
||||
//! - `P;P<code>` adds a number format to the file's list; `F;P<n>;Y<row>;X<col>` gives a cell the
|
||||
//! n-th format (0-based), and `F;W<first> <last> <width>` sets column widths in characters.
|
||||
//!
|
||||
//! Other records (fonts, options, window settings) are skipped. Formulas are converted to A1 with
|
||||
//! the same relative and absolute references, as Excel does when it opens the file.
|
||||
//! SYLK (`.slk`) import. `craft_csv::sylk` reads the file (values, formulas rewritten from R1C1 to
|
||||
//! A1, number formats, column widths); this module makes it a one-sheet workbook: formulas parsed
|
||||
//! (a formula GridCraft can't parse is skipped and counted), error values recognised, formats
|
||||
//! interned as styles and widths converted from characters.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, Value, col_to_letters};
|
||||
use craft_csv::sylk;
|
||||
use gridcraft_core::{CellError, CellRef, Value};
|
||||
use gridcraft_model::{Cell, Formula, NumFmt, Sheet, Style, Workbook};
|
||||
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
/// The largest SYLK file read, as for other text imports: past it the file is refused.
|
||||
const MAX_BYTES: usize = 512 << 20;
|
||||
/// Column-width records kept from one file; each can cover every column.
|
||||
const MAX_WIDTH_RECORDS: usize = 1024;
|
||||
|
||||
/// Reads a SYLK file into a one-sheet workbook. Returns the workbook and its warnings.
|
||||
pub fn read(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
|
||||
if bytes.len() > MAX_BYTES {
|
||||
return Err(EngineError::Other(format!("We can't open '{name}': the SYLK file is larger than {} MB.", MAX_BYTES >> 20)));
|
||||
}
|
||||
let text = match std::str::from_utf8(bytes) {
|
||||
Ok(s) => s.to_string(),
|
||||
// Older exports are in a single-byte code page: keep each byte as its Latin-1 character.
|
||||
Err(_) => bytes.iter().map(|&b| char::from(b)).collect(),
|
||||
};
|
||||
let text = text.strip_prefix('\u{feff}').unwrap_or(&text);
|
||||
if !text.starts_with("ID") {
|
||||
return Err(EngineError::Other(format!("We can't open '{name}': it isn't a SYLK file (it doesn't start with an ID record).")));
|
||||
}
|
||||
let file = sylk::read(bytes).map_err(|e| match e {
|
||||
sylk::Error::TooLarge => EngineError::Other(format!("We can't open '{name}': the SYLK file is larger than {} MB.", sylk::MAX_BYTES >> 20)),
|
||||
_ => EngineError::Other(format!("We can't open '{name}': it isn't a SYLK file (it doesn't start with an ID record).")),
|
||||
})?;
|
||||
let mut wb = Workbook::new();
|
||||
let mut sheet = Sheet::new("Sheet1");
|
||||
let mut formats: Vec<String> = Vec::new();
|
||||
// Cells' number formats by index into `formats`, applied once every format is known.
|
||||
let mut cell_formats: Vec<(CellRef, usize)> = Vec::new();
|
||||
let mut formulas: HashMap<CellRef, String> = HashMap::new();
|
||||
let mut widths: Vec<(u32, u32, f64)> = Vec::new();
|
||||
let (mut row, mut col) = (0u32, 0u32);
|
||||
let mut skipped = 0usize;
|
||||
for line in text.lines() {
|
||||
let fields = split_fields(line);
|
||||
let Some((kind, rest)) = fields.split_first() else { continue };
|
||||
match kind.as_str() {
|
||||
"C" => {
|
||||
let (mut value, mut formula, mut shared) = (None, None, None::<(Option<u32>, Option<u32>)>);
|
||||
let mut bad = false;
|
||||
for f in rest {
|
||||
let (tag, arg) = f.split_at(f.chars().next().map_or(0, char::len_utf8));
|
||||
match tag {
|
||||
"Y" => match position(arg, MAX_ROWS) {
|
||||
Some(r) => row = r,
|
||||
None => bad = true,
|
||||
},
|
||||
"X" => match position(arg, MAX_COLS) {
|
||||
Some(c) => col = c,
|
||||
None => bad = true,
|
||||
},
|
||||
"K" => value = Some(parse_value(arg)),
|
||||
"E" => formula = Some(arg.to_string()),
|
||||
"S" => shared = Some((None, None)),
|
||||
// In a shared-formula cell, R and C name the cell whose formula it shares.
|
||||
"R" if shared.is_some() => shared = shared.map(|(_, c)| (position(arg, MAX_ROWS), c)),
|
||||
"C" if shared.is_some() => shared = shared.map(|(r, _)| (r, position(arg, MAX_COLS))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if bad {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
let at = CellRef::new(row, col);
|
||||
if let Some((Some(r), Some(c))) = shared {
|
||||
formula = formulas.get(&CellRef::new(r, c)).cloned();
|
||||
}
|
||||
let mut cell = sheet.cell(at).cloned().unwrap_or_else(|| Cell::value(Value::Empty));
|
||||
if let Some(v) = value {
|
||||
cell.value = v;
|
||||
}
|
||||
if let Some(r1c1) = formula {
|
||||
let a1 = r1c1_formula_to_a1(&r1c1, at);
|
||||
let f = Formula::new(&a1);
|
||||
if f.expr.is_some() {
|
||||
cell.formula = Some(Arc::new(f));
|
||||
formulas.insert(at, r1c1);
|
||||
} else {
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
sheet.set_cell(at, cell);
|
||||
let mut skipped = file.skipped;
|
||||
let mut styles = HashMap::new();
|
||||
for (&(row, col), c) in &file.cells {
|
||||
let value = match &c.value {
|
||||
sylk::Value::Number(n) => Value::Number(*n),
|
||||
sylk::Value::Text(s) => Value::Text(Arc::from(s.as_str())),
|
||||
sylk::Value::Bool(b) => Value::Bool(*b),
|
||||
sylk::Value::Error(s) => CellError::parse(s).map_or_else(|| Value::Text(Arc::from(s.as_str())), Value::Error),
|
||||
_ => Value::Empty,
|
||||
};
|
||||
let mut cell = Cell::value(value);
|
||||
if let Some(a1) = &c.formula {
|
||||
let f = Formula::new(a1);
|
||||
if f.expr.is_some() {
|
||||
cell.formula = Some(Arc::new(f));
|
||||
} else {
|
||||
skipped += 1;
|
||||
}
|
||||
"F" => {
|
||||
let mut format = None;
|
||||
for f in rest {
|
||||
let (tag, arg) = f.split_at(f.chars().next().map_or(0, char::len_utf8));
|
||||
match tag {
|
||||
"Y" => row = position(arg, MAX_ROWS).unwrap_or(row),
|
||||
"X" => col = position(arg, MAX_COLS).unwrap_or(col),
|
||||
"P" => format = arg.parse::<usize>().ok(),
|
||||
"W" => {
|
||||
let mut parts = arg.split_whitespace();
|
||||
if let (Some(first), Some(last), Some(width)) = (parts.next(), parts.next(), parts.next())
|
||||
&& let (Some(first), Some(last), Ok(width)) =
|
||||
(position(first, MAX_COLS), position(last, MAX_COLS), width.parse::<f64>())
|
||||
&& first <= last
|
||||
&& (0.0..=255.0).contains(&width)
|
||||
// A file of width records can't make the import loop over every column
|
||||
// again and again.
|
||||
&& widths.len() < MAX_WIDTH_RECORDS
|
||||
{
|
||||
widths.push((first, last, width));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(format) = format {
|
||||
cell_formats.push((CellRef::new(row, col), format));
|
||||
}
|
||||
}
|
||||
// Number formats only: `P;F…` and `P;E…` are fonts.
|
||||
"P" => {
|
||||
if let Some(code) = rest.first().and_then(|f| f.strip_prefix('P')) {
|
||||
formats.push(code.to_string());
|
||||
}
|
||||
}
|
||||
"E" => break,
|
||||
_ => {}
|
||||
}
|
||||
if let Some(code) = &c.format {
|
||||
cell.style = *styles
|
||||
.entry(code.as_str())
|
||||
.or_insert_with(|| wb.styles.intern(Style { num_fmt: NumFmt(Arc::from(code.as_str())), ..Style::default() }));
|
||||
}
|
||||
sheet.set_cell(CellRef::new(row, col), cell);
|
||||
}
|
||||
for (first, last, width) in widths {
|
||||
let size = crate::cmd::format::chars_to_points(width) as f32;
|
||||
for c in first..=last {
|
||||
for w in &file.column_widths {
|
||||
let size = crate::cmd::format::chars_to_points(w.width) as f32;
|
||||
for c in w.first..=w.last {
|
||||
sheet.cols.entry(c).or_default().size = Some(size);
|
||||
}
|
||||
}
|
||||
let mut wb = Workbook::new();
|
||||
let mut styles = HashMap::new();
|
||||
for (at, index) in cell_formats {
|
||||
let Some(code) = formats.get(index).filter(|code| !code.is_empty() && *code != "General") else { continue };
|
||||
let style = *styles.entry(index).or_insert_with(|| wb.styles.intern(Style { num_fmt: NumFmt(Arc::from(code.as_str())), ..Style::default() }));
|
||||
let mut cell = sheet.cell(at).cloned().unwrap_or_else(|| Cell::value(Value::Empty));
|
||||
cell.style = style;
|
||||
sheet.set_cell(at, cell);
|
||||
}
|
||||
if let Some(first) = wb.sheet_mut(0) {
|
||||
sheet.name = std::mem::take(&mut first.name);
|
||||
*first = sheet;
|
||||
@@ -161,168 +62,3 @@ pub fn read(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
|
||||
}
|
||||
Ok((wb, warnings))
|
||||
}
|
||||
|
||||
/// A record's fields: `;` separates them and `;;` is a literal semicolon.
|
||||
fn split_fields(line: &str) -> Vec<String> {
|
||||
let mut fields = vec![String::new()];
|
||||
let mut chars = line.trim_end_matches('\r').chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == ';' {
|
||||
if chars.peek() == Some(&';') {
|
||||
chars.next();
|
||||
if let Some(f) = fields.last_mut() {
|
||||
f.push(';');
|
||||
}
|
||||
} else {
|
||||
fields.push(String::new());
|
||||
}
|
||||
} else if let Some(f) = fields.last_mut() {
|
||||
f.push(c);
|
||||
}
|
||||
}
|
||||
fields
|
||||
}
|
||||
|
||||
/// A 1-based row or column number → 0-based, within `max`.
|
||||
fn position(arg: &str, max: u32) -> Option<u32> {
|
||||
arg.trim().parse::<u32>().ok().filter(|n| (1..=max).contains(n)).map(|n| n - 1)
|
||||
}
|
||||
|
||||
/// A `K` value: `"text"` (everything between the first and the last quote, as written), a
|
||||
/// boolean, an error or a number.
|
||||
fn parse_value(arg: &str) -> Value {
|
||||
if let Some(text) = arg.strip_prefix('"') {
|
||||
let text = text.strip_suffix('"').unwrap_or(text);
|
||||
return Value::Text(Arc::from(text));
|
||||
}
|
||||
if arg.eq_ignore_ascii_case("TRUE") || arg.eq_ignore_ascii_case("FALSE") {
|
||||
return Value::Bool(arg.eq_ignore_ascii_case("TRUE"));
|
||||
}
|
||||
if let Some(e) = CellError::parse(arg) {
|
||||
return Value::Error(e);
|
||||
}
|
||||
match arg.trim().parse::<f64>() {
|
||||
Ok(n) if n.is_finite() => Value::Number(n),
|
||||
_ => Value::Text(Arc::from(arg)),
|
||||
}
|
||||
}
|
||||
|
||||
/// One part of an R1C1 reference: `R`/`C` followed by an absolute number, a bracketed offset or
|
||||
/// nothing (the cell's own row or column).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Part {
|
||||
Absolute(u32),
|
||||
Relative(i64),
|
||||
}
|
||||
|
||||
impl Part {
|
||||
/// The 0-based index and whether it is absolute, or `None` when it falls off the sheet.
|
||||
fn resolve(self, base: u32, max: u32) -> Option<(u32, bool)> {
|
||||
match self {
|
||||
Part::Absolute(n) => (1..=max).contains(&n).then(|| (n - 1, true)),
|
||||
Part::Relative(k) => i64::from(base).checked_add(k).and_then(|v| u32::try_from(v).ok()).filter(|v| *v < max).map(|v| (v, false)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses the number after `R` or `C` at `i`: digits, `[offset]` or nothing. Returns the part and
|
||||
/// the index after it, or `None` when the text isn't a reference part.
|
||||
fn parse_part(chars: &[char], i: usize) -> Option<(Part, usize)> {
|
||||
match chars.get(i) {
|
||||
Some('[') => {
|
||||
let close = chars[i..].iter().position(|&c| c == ']')? + i;
|
||||
let inner: String = chars[i + 1..close].iter().collect();
|
||||
Some((Part::Relative(inner.parse().ok()?), close + 1))
|
||||
}
|
||||
Some(c) if c.is_ascii_digit() => {
|
||||
let end = chars[i..].iter().position(|c| !c.is_ascii_digit()).map_or(chars.len(), |p| p + i);
|
||||
let n: String = chars[i..end].iter().collect();
|
||||
Some((Part::Absolute(n.parse().unwrap_or(u32::MAX)), end))
|
||||
}
|
||||
_ => Some((Part::Relative(0), i)),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_word_char(c: char) -> bool {
|
||||
c.is_alphanumeric() || matches!(c, '_' | '.' | '\\' | '?' | '$')
|
||||
}
|
||||
|
||||
/// Rewrites the R1C1 references in a SYLK formula as A1 references for the cell at `at`:
|
||||
/// `RC[-1]` → `B2`, `R1C1` → `$A$1`, `R[-2]C:R[-1]C` → `D2:D3`, a whole column `C4` → `$D:$D` and
|
||||
/// a whole row `R3` → `$3:$3`. Text in quotes and quoted sheet names are left alone; a reference
|
||||
/// off the sheet becomes `#REF!`.
|
||||
pub(crate) fn r1c1_formula_to_a1(formula: &str, at: CellRef) -> String {
|
||||
let chars: Vec<char> = formula.chars().collect();
|
||||
let mut out = String::with_capacity(formula.len());
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
let c = chars[i];
|
||||
if c == '"' || c == '\'' {
|
||||
// Copy the quoted run as it is; a doubled quote stays inside it.
|
||||
let mut j = i + 1;
|
||||
while j < chars.len() && !(chars[j] == c && chars.get(j + 1) != Some(&c)) {
|
||||
j += if chars[j] == c { 2 } else { 1 };
|
||||
}
|
||||
let stop = (j + 1).min(chars.len());
|
||||
out.extend(&chars[i..stop]);
|
||||
i = stop;
|
||||
continue;
|
||||
}
|
||||
let starts_word = i == 0 || !is_word_char(chars[i - 1]);
|
||||
if starts_word
|
||||
&& matches!(c, 'R' | 'r' | 'C' | 'c')
|
||||
&& let Some((text, next)) = reference_at(&chars, i, at)
|
||||
{
|
||||
out.push_str(&text);
|
||||
i = next;
|
||||
continue;
|
||||
}
|
||||
// Copy the rest of a word as it is, so `ROUND` or `Sheet2` isn't read as a reference.
|
||||
if is_word_char(c) {
|
||||
let end = (i..chars.len()).find(|&j| !is_word_char(chars[j])).unwrap_or(chars.len());
|
||||
out.extend(&chars[i..end]);
|
||||
i = end;
|
||||
} else {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The A1 text of the R1C1 reference starting at `i`, and the index after it, or `None` when the
|
||||
/// word there isn't a reference (a function or a name).
|
||||
fn reference_at(chars: &[char], i: usize, at: CellRef) -> Option<(String, usize)> {
|
||||
let mut j = i;
|
||||
let mut row = None;
|
||||
let mut col = None;
|
||||
if matches!(chars.get(j), Some('R' | 'r')) {
|
||||
let (part, next) = parse_part(chars, j + 1)?;
|
||||
row = Some(part);
|
||||
j = next;
|
||||
}
|
||||
if matches!(chars.get(j), Some('C' | 'c')) {
|
||||
let (part, next) = parse_part(chars, j + 1)?;
|
||||
col = Some(part);
|
||||
j = next;
|
||||
}
|
||||
if row.is_none() && col.is_none() {
|
||||
return None;
|
||||
}
|
||||
// A reference ends the word: `ROUND(`, `RATE` or `R2D2` are names.
|
||||
if chars.get(j).is_some_and(|&c| is_word_char(c) || c == '(' || c == '[' || c == '!') {
|
||||
return None;
|
||||
}
|
||||
let row_text = |p: Part| p.resolve(at.row, MAX_ROWS).map(|(r, abs)| format!("{}{}", if abs { "$" } else { "" }, r + 1));
|
||||
let col_text = |p: Part| p.resolve(at.col, MAX_COLS).map(|(c, abs)| format!("{}{}", if abs { "$" } else { "" }, col_to_letters(c)));
|
||||
// One side of a range (`R2:R4`, `C1:C3`) is written alone; a lone row or column is the whole of it.
|
||||
let prev = chars[..i].iter().rev().find(|c| !c.is_whitespace());
|
||||
let in_range = prev == Some(&':') || chars[j..].iter().find(|c| !c.is_whitespace()) == Some(&':');
|
||||
let text = match (row, col) {
|
||||
(Some(r), Some(c)) => row_text(r).zip(col_text(c)).map(|(r, c)| format!("{c}{r}")),
|
||||
(Some(r), None) => row_text(r).map(|r| if in_range { r } else { format!("{r}:{r}") }),
|
||||
(None, Some(c)) => col_text(c).map(|c| if in_range { c } else { format!("{c}:{c}") }),
|
||||
(None, None) => None,
|
||||
};
|
||||
Some((text.unwrap_or_else(|| "#REF!".into()), j))
|
||||
}
|
||||
|
||||
@@ -763,7 +763,7 @@ fn sylk_r1c1_formulas_become_a1() {
|
||||
("R[-5]C", "#REF!"),
|
||||
("\"RC\"&R1C1", "\"RC\"&$A$1"),
|
||||
] {
|
||||
assert_eq!(sylk::r1c1_formula_to_a1(r1c1, b2), a1, "{r1c1}");
|
||||
assert_eq!(craft_csv::sylk::r1c1_formula_to_a1(r1c1, (b2.row, b2.col)), a1, "{r1c1}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -810,3 +810,74 @@ fn reading_a_fifo_is_refused_without_blocking() {
|
||||
assert!(err.contains("only regular files"), "{err}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Merges far from what is copied or exported, in a hostile number, are indexed once instead of
|
||||
/// being scanned for every cell; a merge running past the table is clipped to it.
|
||||
#[test]
|
||||
fn html_merge_lookups_and_spans_stay_bounded() {
|
||||
let mut wb = book_with_picture("A1", (0, 0));
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
for i in 0..200_000u32 {
|
||||
sh.merges.push(RangeRef::new(CellRef::new(500_000 + i * 2, 5), CellRef::new(500_000 + i * 2, 6)));
|
||||
}
|
||||
sh.merges.push(RangeRef::new(CellRef::new(0, 0), CellRef::new(gridcraft_core::MAX_ROWS - 1, 1)));
|
||||
sh.set_value(CellRef::new(2, 1), gridcraft_core::Value::Number(2.0));
|
||||
let html = copied(&wb, "A1:C50");
|
||||
assert!(html.contains("rowspan=\"50\" colspan=\"2\""), "{html}");
|
||||
let page = to_html(&wb, 0).unwrap();
|
||||
// The table is A1:B3 (the used range): the whole-height merge spans its three rows only.
|
||||
assert!(page.contains("rowspan=\"3\" colspan=\"2\""), "{page}");
|
||||
// Merges stacked over one another past a few per cell are refused, not walked.
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
sh.merges = vec![RangeRef::new(CellRef::new(0, 0), CellRef::new(2, 1)); 100];
|
||||
assert!(to_html(&wb, 0).is_err());
|
||||
assert!(range_html(&wb, 0, RangeRef::parse("A1:B3").unwrap(), "").is_none());
|
||||
}
|
||||
|
||||
/// Thousands of pictures over thousands of custom rows: each is placed through a row index.
|
||||
#[test]
|
||||
fn html_export_places_many_pictures_over_many_custom_rows() {
|
||||
let mut wb = book_with_picture("A1", (0, 0));
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
for row in 0..50_000 {
|
||||
sh.rows.insert(row, gridcraft_model::LineInfo { size: Some(30.0), ..Default::default() });
|
||||
}
|
||||
let image = sh.images[0].clone();
|
||||
for i in 0..5_000u32 {
|
||||
let mut im = image.clone();
|
||||
im.anchor.cell = CellRef::new(40_000 + i, 0);
|
||||
sh.images.push(im);
|
||||
}
|
||||
let page = to_html(&wb, 0).unwrap();
|
||||
assert!(page.contains(&format!("top:{}px", 30 * 44_999)), "last picture placed by the index");
|
||||
}
|
||||
|
||||
/// One long text shown by many cells: the page fails at its byte limit instead of growing on.
|
||||
#[test]
|
||||
fn html_export_refuses_a_page_past_its_byte_limit() {
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
let text = gridcraft_core::Value::text("x".repeat(32_000));
|
||||
for row in 0..10_000 {
|
||||
sh.set_value(CellRef::new(row, 0), text.clone());
|
||||
}
|
||||
let err = to_html(&wb, 0).unwrap_err();
|
||||
assert!(err.to_string().contains("MiB"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaping_stops_at_the_room_left() {
|
||||
assert_eq!(esc_within("a&b<c>\"d", 100).as_deref(), Some(esc("a&b<c>\"d").as_str()));
|
||||
assert_eq!(esc_within("&&&", 14), None, "three `&` are 15 bytes");
|
||||
assert_eq!(esc_within("&&&", 15).as_deref(), Some("&&&"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_merge_index_holds_a_bounded_number_of_cells() {
|
||||
let mut sh = Sheet::new("S");
|
||||
let rect = RangeRef::new(CellRef::new(0, 0), CellRef::new(1_100_000, 0));
|
||||
sh.merges.push(RangeRef::new(CellRef::new(0, 0), CellRef::new(999_999, 0)));
|
||||
assert_eq!(merge_index(&sh, rect).map(|m| m.len()), Some(1_000_000));
|
||||
sh.merges = vec![RangeRef::new(CellRef::new(0, 0), CellRef::new(1_000_000, 0))];
|
||||
assert!(merge_index(&sh, rect).is_none());
|
||||
}
|
||||
|
||||
@@ -1281,9 +1281,18 @@ fn content_differs(b: Option<&Cell>, a: Option<&Cell>) -> bool {
|
||||
/// Objects added, changed and removed between two versions of a sheet.
|
||||
fn objects(b: &Sheet, a: &Sheet) -> (usize, usize, usize) {
|
||||
fn count<T: PartialEq>(b: &[T], a: &[T], id: impl Fn(&T) -> u32) -> (usize, usize, usize) {
|
||||
let added = a.iter().filter(|x| !b.iter().any(|y| id(y) == id(x))).count();
|
||||
let removed = b.iter().filter(|x| !a.iter().any(|y| id(y) == id(x))).count();
|
||||
let changed = b.iter().filter(|x| a.iter().any(|y| id(y) == id(x) && y != *x)).count();
|
||||
// Objects grouped by id, so each lookup is a map probe instead of a scan of the list.
|
||||
fn group<'a, T>(v: &'a [T], id: &impl Fn(&T) -> u32) -> std::collections::HashMap<u32, Vec<&'a T>> {
|
||||
let mut m: std::collections::HashMap<u32, Vec<&T>> = std::collections::HashMap::new();
|
||||
for x in v {
|
||||
m.entry(id(x)).or_default().push(x);
|
||||
}
|
||||
m
|
||||
}
|
||||
let (gb, ga) = (group(b, &id), group(a, &id));
|
||||
let added = a.iter().filter(|x| !gb.contains_key(&id(x))).count();
|
||||
let removed = b.iter().filter(|x| !ga.contains_key(&id(x))).count();
|
||||
let changed = b.iter().filter(|x| ga.get(&id(x)).is_some_and(|ys| ys.iter().any(|y| *y != *x))).count();
|
||||
(added, changed, removed)
|
||||
}
|
||||
let parts = [
|
||||
|
||||
@@ -153,6 +153,11 @@ pub fn compile(formula: &str, field: &dyn Fn(&str) -> Option<String>) -> Result<
|
||||
Expr::Name(n) if n.starts_with(PREFIX) => {}
|
||||
Expr::Name(n) => problem = Some(format!("There's no field named '{n}'.")),
|
||||
Expr::Ref(_) | Expr::Struct(_) => problem = Some("A calculated field can't refer to cells or tables; use field names.".into()),
|
||||
// Evaluated once per group and per report, with only the first value kept: Excel does not
|
||||
// allow functions that need arrays in a calculated field, and these build one from scalars.
|
||||
Expr::Call(f, _) if matches!(f.as_str(), "SEQUENCE" | "RANDARRAY" | "MAKEARRAY" | "EXPAND" | "MUNIT") => {
|
||||
problem = Some(format!("A calculated field can't use {f}: it builds an array."))
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
@@ -332,6 +337,16 @@ mod tests {
|
||||
assert!(compile(&format!("={}", "1+".repeat(MAX_FORMULA_LEN)), &fields).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_calculated_field_cannot_build_arrays_from_scalars() {
|
||||
for f in
|
||||
["=SEQUENCE(5000,1000)", "=SUM(RANDARRAY(1000,1000))", "=MAKEARRAY(9,9,LAMBDA(r,c,r*c))+Sales", "=expand(Sales,1000,1000)", "=MUNIT(500)"]
|
||||
{
|
||||
assert!(compile(f, &fields).unwrap_err().contains("builds an array"), "{f}");
|
||||
}
|
||||
assert!(compile("=SUM({1,2,3})*Sales", &fields).is_ok(), "an array constant is bounded by the formula's own length");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_and_cyclic_chains_are_bounded() {
|
||||
// 300 calculated fields, each using the previous one, and a two-field cycle.
|
||||
|
||||
+141
-17
@@ -43,6 +43,10 @@ impl Selection {
|
||||
pub fn is_single_cell(&self) -> bool {
|
||||
self.ranges.len() == 1 && self.current().is_single()
|
||||
}
|
||||
/// Whether the selection is exactly one merged cell, which Enter and Tab treat as one cell.
|
||||
pub fn is_one_merged_cell(&self, sheet: &Sheet) -> bool {
|
||||
self.ranges.len() == 1 && sheet.merge_at(self.active).is_some_and(|m| m == self.current())
|
||||
}
|
||||
/// The corner of the current area opposite the anchor: the end that Shift+arrows move.
|
||||
pub fn moving_end(&self) -> CellRef {
|
||||
let r = self.current();
|
||||
@@ -67,23 +71,28 @@ impl Selection {
|
||||
/// they are: clicking column B's header selects just B even when B1:C1 is merged, as in Excel.
|
||||
pub fn expand_merges(&mut self, sheet: &Sheet) {
|
||||
for r in self.ranges.iter_mut() {
|
||||
if r.is_full_cols() || r.is_full_rows() {
|
||||
continue;
|
||||
}
|
||||
let mut cur = *r;
|
||||
loop {
|
||||
let mut grown = cur;
|
||||
for m in &sheet.merges {
|
||||
if m.intersects(&grown) {
|
||||
grown = grown.union(m);
|
||||
}
|
||||
}
|
||||
if grown == cur {
|
||||
break;
|
||||
}
|
||||
cur = grown;
|
||||
}
|
||||
*r = cur;
|
||||
*r = with_merges(sheet, *r);
|
||||
}
|
||||
}
|
||||
/// Shift+arrows: moves the edge of the current area that the anchor doesn't hold by `dr`
|
||||
/// rows and `dc` columns, keeping merged cells whole, as Excel does. The anchor is the whole
|
||||
/// merged cell it is in, so from a selected merged cell Shift+Left and Shift+Up widen the
|
||||
/// selection past its left and top edges. A step that would shrink the area onto the inside
|
||||
/// of a merged cell (where keeping it whole would undo the step) goes on to the next line
|
||||
/// that does shrink it, and when the area can't shrink any more it grows in that direction.
|
||||
pub fn extend_by(&mut self, sheet: &Sheet, dr: i64, dc: i64) {
|
||||
let anchor = sheet.merge_at(self.anchor).unwrap_or(RangeRef::cell(self.anchor));
|
||||
let mut r = self.current();
|
||||
if dc != 0 {
|
||||
r = step_edge(sheet, r, anchor, dc, true);
|
||||
}
|
||||
if dr != 0 {
|
||||
r = step_edge(sheet, r, anchor, dr, false);
|
||||
}
|
||||
if let Some(last) = self.ranges.last_mut() {
|
||||
*last = r;
|
||||
} else {
|
||||
self.ranges.push(r);
|
||||
}
|
||||
}
|
||||
/// Moves the active cell within the selection (Enter/Tab inside a multi-cell selection).
|
||||
@@ -128,6 +137,91 @@ impl Selection {
|
||||
}
|
||||
}
|
||||
|
||||
/// `r` grown until every merged cell it touches is inside it. Whole columns and whole rows stay
|
||||
/// as they are.
|
||||
fn with_merges(sheet: &Sheet, r: RangeRef) -> RangeRef {
|
||||
if r.is_full_cols() || r.is_full_rows() {
|
||||
return r;
|
||||
}
|
||||
let mut cur = r;
|
||||
loop {
|
||||
let mut grown = cur;
|
||||
for m in &sheet.merges {
|
||||
if m.intersects(&grown) {
|
||||
grown = grown.union(m);
|
||||
}
|
||||
}
|
||||
if grown == cur {
|
||||
return cur;
|
||||
}
|
||||
cur = grown;
|
||||
}
|
||||
}
|
||||
|
||||
/// One Shift+arrow step of [`Selection::extend_by`] along one axis (`cols`: columns, else rows).
|
||||
fn step_edge(sheet: &Sheet, r: RangeRef, anchor: RangeRef, d: i64, cols: bool) -> RangeRef {
|
||||
let max = i64::from(if cols { MAX_COLS } else { MAX_ROWS }) - 1;
|
||||
let hidden = |i: i64| if cols { sheet.is_col_hidden(i as u32) } else { sheet.is_row_hidden(i as u32) };
|
||||
// `from` moved by `by`, then on past hidden lines in the same direction (not off the sheet).
|
||||
let land = |from: i64, by: i64| {
|
||||
let mut t = from.saturating_add(by).clamp(0, max);
|
||||
// Bounded like the arrow keys' walk over hidden lines.
|
||||
for _ in 0..100_000 {
|
||||
if !hidden(t) {
|
||||
break;
|
||||
}
|
||||
let n = (t + by.signum()).clamp(0, max);
|
||||
if n == t {
|
||||
break;
|
||||
}
|
||||
t = n;
|
||||
}
|
||||
t
|
||||
};
|
||||
let span = |x: &RangeRef| {
|
||||
if cols { (i64::from(x.start.col), i64::from(x.end.col)) } else { (i64::from(x.start.row), i64::from(x.end.row)) }
|
||||
};
|
||||
let with = |lo: i64, hi: i64| {
|
||||
let (lo, hi) = (lo.clamp(0, max) as u32, hi.clamp(0, max) as u32);
|
||||
let (s, e) = if cols {
|
||||
(CellRef::new(r.start.row, lo), CellRef::new(r.end.row, hi))
|
||||
} else {
|
||||
(CellRef::new(lo, r.start.col), CellRef::new(hi, r.end.col))
|
||||
};
|
||||
with_merges(sheet, RangeRef::new(s, e))
|
||||
};
|
||||
let (lo, hi) = span(&r);
|
||||
let (alo, ahi) = span(&anchor);
|
||||
// The anchor holds the low edge unless the area reaches past it on that side.
|
||||
let moving_hi = lo >= alo;
|
||||
let shrinking = if moving_hi { d < 0 && hi > ahi } else { d > 0 && lo < alo };
|
||||
if shrinking {
|
||||
let mut t = if moving_hi { hi } else { lo };
|
||||
let mut by = d;
|
||||
loop {
|
||||
t = if moving_hi { land(t, by).max(ahi) } else { land(t, by).min(alo) };
|
||||
let cand = if moving_hi { with(lo, t) } else { with(t, hi) };
|
||||
if cand != r {
|
||||
return cand;
|
||||
}
|
||||
if t == if moving_hi { ahi } else { alo } {
|
||||
break;
|
||||
}
|
||||
// The step was undone because a merged cell crosses the new edge, and so is every
|
||||
// edge up to where that merged cell starts (ends, for the low edge): go on from there
|
||||
// so a tall or wide merged cell costs one step, not one per line it covers.
|
||||
let across = sheet.merges.iter().filter(|m| m.intersects(&r)).filter_map(|m| {
|
||||
let (ms, me) = span(m);
|
||||
if moving_hi { (ms <= t && me > t).then_some(ms) } else { (ms < t && me >= t).then_some(me) }
|
||||
});
|
||||
t = if moving_hi { across.min().map_or(t, |ms| ms.min(t)) } else { across.max().map_or(t, |me| me.max(t)) };
|
||||
by = d.signum();
|
||||
}
|
||||
}
|
||||
// Grow the area on the side the arrow points to.
|
||||
if d > 0 { with(lo, land(hi, d)) } else { with(land(lo, d), hi) }
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
/// Ctrl+. (Next Corner of Selection): the active cell moves clockwise to the next corner of
|
||||
/// the current area (top-left, top-right, bottom-right, bottom-left) and becomes the anchor,
|
||||
@@ -287,6 +381,36 @@ mod tests {
|
||||
assert_eq!(current_region(&s, CellRef::new(5, 0)).a1(), "A1:B20000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_arrows_from_a_merged_cell() {
|
||||
let mut s = Sheet::new("S");
|
||||
s.merges.push(RangeRef::parse("B2:C3").unwrap());
|
||||
let at = |s: &Sheet, a1: &str| {
|
||||
let mut sel = Selection::at(CellRef::parse(a1).unwrap());
|
||||
sel.expand_merges(s);
|
||||
sel
|
||||
};
|
||||
for ((dr, dc), want) in [((0, -1), "A2:C3"), ((-1, 0), "B1:C3"), ((0, 1), "B2:D3"), ((1, 0), "B2:C4")] {
|
||||
let mut sel = at(&s, "B2");
|
||||
sel.extend_by(&s, dr, dc);
|
||||
assert_eq!(sel.a1(), want);
|
||||
}
|
||||
// Shrinking onto the inside of the merged cell goes on past it.
|
||||
let mut sel = at(&s, "A1");
|
||||
sel.extend_by(&s, 1, 1);
|
||||
assert_eq!(sel.a1(), "A1:C3");
|
||||
sel.extend_by(&s, 0, -1);
|
||||
assert_eq!(sel.a1(), "A1:A3");
|
||||
// A hidden column is stepped over, and the sheet edge stops the area.
|
||||
s.cols.entry(3).or_default().hidden = true;
|
||||
let mut sel = at(&s, "B2");
|
||||
sel.extend_by(&s, 0, 1);
|
||||
assert_eq!(sel.a1(), "B2:E3");
|
||||
let mut sel = at(&s, "A1");
|
||||
sel.extend_by(&s, -1, -1);
|
||||
assert_eq!(sel.a1(), "A1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycling() {
|
||||
let mut sel = Selection::range(RangeRef::parse("A1:B2").unwrap());
|
||||
|
||||
@@ -532,6 +532,118 @@ fn shape_text_edit_survives_xlsx_roundtrip() {
|
||||
assert_eq!(shape.text, text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_art_insert_respects_sheet_protection() {
|
||||
let mut s = s();
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
let before = s.doc().unwrap().wb.clone();
|
||||
let undo_len = s.doc().unwrap().undo.len();
|
||||
assert!(s.execute("insert.wordArt", json!({})).is_err());
|
||||
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &before));
|
||||
assert_eq!(s.doc().unwrap().undo.len(), undo_len);
|
||||
s.execute("review.unprotectSheet", json!({})).unwrap();
|
||||
s.execute("insert.wordArt", json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().shapes.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_art_inserts_with_defaults_and_undoes() {
|
||||
use gridcraft_model::ShapeKind;
|
||||
let mut s = s();
|
||||
// Empty params: default style and text, no dialog.
|
||||
let r = s.execute("insert.wordArt", json!({})).unwrap();
|
||||
assert!(s.take_ui_requests().is_empty());
|
||||
let shape = s.doc().unwrap().wb.active().unwrap().shapes[0].clone();
|
||||
assert_eq!(shape.id, r["shape"].as_u64().unwrap() as u32);
|
||||
assert_eq!(shape.kind, ShapeKind::WordArt);
|
||||
assert_eq!(shape.text, crate::cmd::wordart::DEFAULT_TEXT);
|
||||
assert!(shape.anchor.width > 8.0 && shape.format.text_color.is_some());
|
||||
// Named style plus overrides; restyle and edit the text through wordArt.set.
|
||||
s.execute("insert.wordArt", json!({"style": "ember", "text": "Q3", "fontSize": 60, "shadow": false, "outline": "#112233", "outlineWidth": 3}))
|
||||
.unwrap();
|
||||
let ember = s.doc().unwrap().wb.active().unwrap().shapes[1].clone();
|
||||
assert_eq!(ember.format.font_size, Some(60.0));
|
||||
assert_eq!((ember.format.text_shadow, ember.format.text_outline.map(|o| o.1)), (None, Some(3.0)));
|
||||
s.execute("wordArt.set", json!({"id": ember.id, "style": 3, "text": "Q4"})).unwrap();
|
||||
let styled = s.doc().unwrap().wb.active().unwrap().shapes[1].clone();
|
||||
assert_eq!((styled.text.as_str(), styled.format.italic), ("Q4", true));
|
||||
s.execute("shape.setText", json!({"id": ember.id, "text": "Q5"})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().shapes[1], ember);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().shapes[1].text, "Q4");
|
||||
// Moves and deletes like any floating shape.
|
||||
s.execute("object.move", json!({"kind": "shape", "id": ember.id, "at": "D4"})).unwrap();
|
||||
s.execute("object.delete", json!({"kind": "shape", "id": ember.id})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().shapes.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_art_rejects_hostile_params() {
|
||||
let mut s = s();
|
||||
let id = s.execute("insert.wordArt", json!({})).unwrap()["shape"].clone();
|
||||
let rectangle = s.execute("insert.shape", json!({"kind": "rectangle"})).unwrap()["shape"].clone();
|
||||
let before = s.doc().unwrap().wb.clone();
|
||||
for (cmd, params) in [
|
||||
("insert.wordArt", json!({"style": "No Such Style"})),
|
||||
("insert.wordArt", json!({"style": 99})),
|
||||
("insert.wordArt", json!({"style": -1})),
|
||||
("insert.wordArt", json!({"text": 5})),
|
||||
("insert.wordArt", json!({"text": ""})),
|
||||
("insert.wordArt", json!({"text": "x".repeat(crate::cmd::wordart::MAX_CHARS + 1)})),
|
||||
("insert.wordArt", json!({"fill": "not a colour"})),
|
||||
("wordArt.set", json!({})),
|
||||
("wordArt.set", json!({"id": 999})),
|
||||
("wordArt.set", json!({"id": rectangle})),
|
||||
("shape.setText", json!({"id": id, "text": ""})),
|
||||
] {
|
||||
assert!(matches!(s.execute(cmd, params), Err(crate::EngineError::BadParams { .. })), "{cmd}");
|
||||
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &before));
|
||||
}
|
||||
// Absurd sizes are clamped, not trusted.
|
||||
s.execute("insert.wordArt", json!({"fontSize": 1e12, "width": 1e300, "height": -5})).unwrap();
|
||||
let big = &s.doc().unwrap().wb.active().unwrap().shapes[2];
|
||||
assert!(big.anchor.width <= 4000.0 && big.anchor.height >= 8.0 && big.format.font_size == Some(400.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_art_survives_xlsx_roundtrip() {
|
||||
use gridcraft_model::ShapeKind;
|
||||
let mut s = s();
|
||||
s.execute("insert.wordArt", json!({"style": "Lagoon", "text": "Sales <2026>\nOutlook & more"})).unwrap();
|
||||
let before = s.doc().unwrap().wb.active().unwrap().shapes[0].clone();
|
||||
let saved = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
|
||||
s.execute("file.open", json!({"name": "wordart.xlsx", "base64": saved["base64"]})).unwrap();
|
||||
let after = &s.doc().unwrap().wb.active().unwrap().shapes[0];
|
||||
assert_eq!(after.kind, ShapeKind::WordArt);
|
||||
assert_eq!(after.text, before.text);
|
||||
assert_eq!(after.format.text_color, before.format.text_color);
|
||||
assert_eq!(after.format.text_outline, before.format.text_outline);
|
||||
assert_eq!(after.format.text_shadow, before.format.text_shadow);
|
||||
assert_eq!((after.format.bold, after.format.italic, after.format.font_size), (true, true, before.format.font_size));
|
||||
assert!(after.format.no_line);
|
||||
}
|
||||
|
||||
/// White text with a green outline (Mint Outline) prints its outline, so it is not invisible on a
|
||||
/// white page.
|
||||
#[test]
|
||||
fn word_art_outline_prints() {
|
||||
let mut s = s();
|
||||
s.execute("insert.wordArt", json!({"style": "Mint Outline", "text": "VISIBLE", "at": "A1"})).unwrap();
|
||||
let pdf = s.execute("file.exportPdf", json!({"range": "A1:G10"})).unwrap();
|
||||
let data = crate::io::base64_decode(pdf["base64"].as_str().unwrap()).unwrap();
|
||||
let text = String::from_utf8_lossy(&data);
|
||||
let outline = text.matches("0.18 0.608 0.416 rg").count();
|
||||
assert!(outline >= 1, "the outline colour (2E9B6A) is drawn");
|
||||
assert_eq!(text.matches("(VISIBLE) Tj").count(), 9, "eight outline copies under the white text");
|
||||
assert!(text.contains("1 1 1 rg"), "the white fill is drawn on top");
|
||||
// The last copy drawn is the filled text, in white; every outline copy comes before it.
|
||||
let before_fill = &text[..text.rfind("(VISIBLE) Tj").unwrap()];
|
||||
assert!(before_fill.rfind("1 1 1 rg") > before_fill.rfind("0.18 0.608 0.416 rg"), "the white fill is drawn last");
|
||||
assert_eq!(before_fill.matches("(VISIBLE) Tj").count(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_border_clears_only_shared_edges_and_undo_restores_them() {
|
||||
use gridcraft_core::RangeRef;
|
||||
@@ -3612,6 +3724,8 @@ fn xls_fixture() -> Vec<u8> {
|
||||
let mut put = |at: usize, v: &[u8]| b[at..at + v.len()].copy_from_slice(v);
|
||||
put(0, &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]);
|
||||
put(26, &3u16.to_le_bytes());
|
||||
// The byte order mark [MS-CFB] 2.2 requires; craft-cfb refuses a header without it.
|
||||
put(28, &0xFFFEu16.to_le_bytes());
|
||||
put(30, &9u16.to_le_bytes());
|
||||
put(32, &6u16.to_le_bytes());
|
||||
put(44, &1u32.to_le_bytes());
|
||||
@@ -6480,6 +6594,19 @@ fn clearing_links_and_notes_keeps_allow_edit_ranges() {
|
||||
assert!(sh.hyperlinks.is_empty() && sh.comments.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_does_not_search_hidden_formula_source() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)*7"})).unwrap();
|
||||
s.execute("home.formatCells", json!({"range": "A1", "style": {"protection": {"locked": true, "hidden": true}}})).unwrap();
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
// Both give the same answer, and it is not an error: a hidden formula's source is not searched.
|
||||
let hit = s.execute("edit.replace", json!({"what": "SEQUENCE", "with": "X"})).unwrap();
|
||||
let miss = s.execute("edit.replace", json!({"what": "NOSUCHTEXT", "with": "X"})).unwrap();
|
||||
assert_eq!(hit, miss);
|
||||
assert_eq!(hit["replaced"], json!(0), "{hit}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formulatext_does_not_reveal_a_hidden_formula() {
|
||||
let mut s = s();
|
||||
@@ -6556,6 +6683,19 @@ fn pivot_new_sheet_destination_respects_workbook_structure_protection() {
|
||||
assert!(s.execute("insert.pivotTable", json!({"source": "A1:B3", "location": "New"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circle_invalid_says_when_its_scan_allowance_ended_early() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[-1]]})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "A200000", "values": [[-1]]})).unwrap();
|
||||
s.execute("data.validation", json!({"range": "A1:A5", "type": "whole", "operator": "greater", "formula1": "0"})).unwrap();
|
||||
let out = s.execute("data.circleInvalid", json!({})).unwrap();
|
||||
assert_eq!((out["invalid"].as_array().map(Vec::len), out["truncated"].as_bool()), (Some(1), Some(false)), "{out}");
|
||||
s.execute("data.validation", json!({"range": "A1:A200000", "type": "whole", "operator": "greater", "formula1": "0"})).unwrap();
|
||||
let out = s.execute("data.circleInvalid", json!({})).unwrap();
|
||||
assert_eq!(out["truncated"].as_bool(), Some(true), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_charges_a_changed_sheets_comment_text() {
|
||||
let mut s = s();
|
||||
|
||||
@@ -18,6 +18,7 @@ mod hidden_sheet_navigation;
|
||||
mod ink_roundtrip;
|
||||
mod line_count_overflow;
|
||||
mod long_operator_chains;
|
||||
mod merged_selection_keys;
|
||||
mod offset_overflow;
|
||||
mod percent_entry;
|
||||
mod pivot_unknown_func;
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
//! Shift+arrows and Enter/Tab on merged cells, as Excel does them.
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
/// A new workbook with B2:C3 merged and nothing selected but A1.
|
||||
fn merged_book() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("selection.set", json!({"range": "B2:C3"})).unwrap();
|
||||
s.execute("home.mergeCells", json!({})).unwrap();
|
||||
s.execute("selection.set", json!({"cell": "A1"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn shift(s: &mut Session, dr: i64, dc: i64) -> String {
|
||||
s.execute("selection.move", json!({"dr": dr, "dc": dc, "extend": true})).unwrap();
|
||||
s.doc().unwrap().selection.a1()
|
||||
}
|
||||
|
||||
fn enter(s: &mut Session, forward: bool, by_row: bool) -> (String, String) {
|
||||
s.execute("selection.next", json!({"forward": forward, "byRow": by_row})).unwrap();
|
||||
let sel = &s.doc().unwrap().selection;
|
||||
(sel.active.a1(), sel.a1())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_arrows_from_a_merged_cell_widen_past_each_edge() {
|
||||
let mut s = merged_book();
|
||||
for ((dr, dc), want) in [((0, -1), "A2:C3"), ((-1, 0), "B1:C3"), ((0, 1), "B2:D3"), ((1, 0), "B2:C4")] {
|
||||
s.execute("selection.set", json!({"cell": "B2"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.a1(), "B2:C3");
|
||||
assert_eq!(shift(&mut s, dr, dc), want, "Shift+({dr},{dc}) from the merged cell B2:C3");
|
||||
assert_eq!(s.doc().unwrap().selection.active.a1(), "B2", "the active cell stays");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_arrows_keep_going_and_come_back_to_the_merged_cell() {
|
||||
let mut s = merged_book();
|
||||
s.execute("selection.set", json!({"cell": "B2"})).unwrap();
|
||||
assert_eq!(shift(&mut s, 1, 0), "B2:C4");
|
||||
assert_eq!(shift(&mut s, 1, 0), "B2:C5");
|
||||
assert_eq!(shift(&mut s, 1, 0), "B2:C6");
|
||||
assert_eq!(shift(&mut s, -1, 0), "B2:C5");
|
||||
s.execute("selection.set", json!({"cell": "B2"})).unwrap();
|
||||
assert_eq!(shift(&mut s, 0, -1), "A2:C3");
|
||||
assert_eq!(shift(&mut s, 0, 1), "B2:C3", "back to just the merged cell");
|
||||
assert_eq!(shift(&mut s, 0, 1), "B2:D3");
|
||||
assert_eq!(shift(&mut s, -1, 0), "B1:D3");
|
||||
assert_eq!(shift(&mut s, 1, 0), "B2:D3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_arrows_shrink_past_a_merged_cell_they_pulled_in() {
|
||||
let mut s = merged_book();
|
||||
assert_eq!(shift(&mut s, 0, 1), "A1:B1");
|
||||
assert_eq!(shift(&mut s, 1, 0), "A1:C3", "B2 pulls in the whole merged cell");
|
||||
assert_eq!(shift(&mut s, 0, -1), "A1:A3", "one step back leaves the merged cell out");
|
||||
assert_eq!(shift(&mut s, 0, 1), "A1:C3");
|
||||
assert_eq!(shift(&mut s, -1, 0), "A1:C1");
|
||||
assert_eq!(shift(&mut s, 1, 0), "A1:C3");
|
||||
assert_eq!(shift(&mut s, 1, 0), "A1:C4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_and_tab_leave_a_selected_merged_cell() {
|
||||
let mut s = merged_book();
|
||||
for (forward, by_row, want) in [(true, false, "B4"), (false, false, "B1"), (true, true, "D2"), (false, true, "A2")] {
|
||||
s.execute("selection.set", json!({"cell": "B2"})).unwrap();
|
||||
assert_eq!(enter(&mut s, forward, by_row), (want.to_string(), want.to_string()), "forward {forward} byRow {by_row}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_and_tab_out_of_a_merged_cell_step_over_hidden_lines() {
|
||||
let mut s = merged_book();
|
||||
s.execute("selection.set", json!({"range": "A4"})).unwrap();
|
||||
s.execute("home.hideRows", json!({})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "D1"})).unwrap();
|
||||
s.execute("home.hideColumns", json!({})).unwrap();
|
||||
s.execute("selection.set", json!({"cell": "B2"})).unwrap();
|
||||
assert_eq!(enter(&mut s, true, false).0, "B5", "row 4 is hidden");
|
||||
s.execute("selection.set", json!({"cell": "B2"})).unwrap();
|
||||
assert_eq!(enter(&mut s, true, true).0, "E2", "column D is hidden");
|
||||
s.execute("selection.set", json!({"cell": "B2"})).unwrap();
|
||||
s.execute("selection.move", json!({"dr": 1})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.a1(), "B5", "the Down arrow too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_inside_a_selection_visits_a_merged_cell_once() {
|
||||
let mut s = merged_book();
|
||||
s.execute("selection.set", json!({"range": "A1:C3", "active": "A1"})).unwrap();
|
||||
let seen: Vec<String> = (0..6).map(|_| enter(&mut s, true, false).0).collect();
|
||||
assert_eq!(seen, ["A2", "A3", "B1", "B2", "C1", "A1"]);
|
||||
assert_eq!(s.doc().unwrap().selection.a1(), "A1:C3", "the selection stays");
|
||||
let back: Vec<String> = (0..2).map(|_| enter(&mut s, false, false).0).collect();
|
||||
assert_eq!(back, ["C1", "B2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_arrow_that_cannot_shrink_the_area_grows_it() {
|
||||
// A2:B3 merged, anchor B1: Shift+Down pulls the area out to column A, past the anchor.
|
||||
// Shift+Right can't shrink it from the left (the merged cell would come back), so it grows.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("selection.set", json!({"range": "A2:B3"})).unwrap();
|
||||
s.execute("home.mergeCells", json!({})).unwrap();
|
||||
s.execute("selection.set", json!({"cell": "B1"})).unwrap();
|
||||
assert_eq!(shift(&mut s, 1, 0), "A1:B3");
|
||||
assert_eq!(shift(&mut s, 0, 1), "A1:C3");
|
||||
assert_eq!(s.doc().unwrap().selection.active.a1(), "B1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_left_shrinks_past_a_chain_of_merged_cells_in_one_step() {
|
||||
// B2:C3 and D3:E4 are both inside A1:E4. Shift+Left from E can't stop in column D (D3:E4
|
||||
// would come back) and stops in C, where B2:C3 is whole.
|
||||
let mut s = merged_book();
|
||||
s.execute("selection.set", json!({"range": "D3:E4"})).unwrap();
|
||||
s.execute("home.mergeCells", json!({})).unwrap();
|
||||
s.execute("selection.set", json!({"cell": "A1"})).unwrap();
|
||||
assert_eq!(shift(&mut s, 3, 4), "A1:E4");
|
||||
assert_eq!(shift(&mut s, 0, -1), "A1:C4");
|
||||
assert_eq!(shift(&mut s, -1, 0), "A1:C3", "row 4 holds nothing merged once column D is out");
|
||||
assert_eq!(shift(&mut s, 0, -1), "A1:A3", "C can't stop inside B2:C3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_up_over_a_tall_merged_cell_takes_one_step() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("selection.set", json!({"range": "B2:C500000"})).unwrap();
|
||||
s.execute("home.mergeCells", json!({})).unwrap();
|
||||
s.execute("selection.set", json!({"cell": "A1"})).unwrap();
|
||||
assert_eq!(shift(&mut s, 0, 1), "A1:B1");
|
||||
assert_eq!(shift(&mut s, 1, 0), "A1:C500000");
|
||||
assert_eq!(shift(&mut s, -1, 0), "A1:C1", "row 1 is the last row above the merged cell");
|
||||
assert_eq!(shift(&mut s, 1, 0), "A1:C500000");
|
||||
assert_eq!(shift(&mut s, 0, -1), "A1:A500000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_arrows_change_only_the_last_area() {
|
||||
let mut s = merged_book();
|
||||
s.execute("selection.set", json!({"range": "E5:F6,B2"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.a1(), "E5:F6,B2:C3");
|
||||
assert_eq!(shift(&mut s, 0, -1), "E5:F6,A2:C3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_skips_a_merged_cell_whose_start_is_outside_the_selection() {
|
||||
// A whole column isn't widened to the merged cells it touches. B1 is covered by A1:B1, whose
|
||||
// top-left cell is outside column B, so Enter skips it.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("selection.set", json!({"range": "A1:B1"})).unwrap();
|
||||
s.execute("home.mergeCells", json!({})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "B:B", "active": "B2"})).unwrap();
|
||||
assert_eq!(enter(&mut s, false, false).0, "B1048576", "B1 is skipped, going up wraps to the bottom");
|
||||
}
|
||||
@@ -69,11 +69,35 @@ fn parse_top(src: &str, locale: crate::FormulaLocale, allow_union: bool) -> Resu
|
||||
let mut p = Parser { toks, i: 0, depth: 0, input: locale == crate::FormulaLocale::Es };
|
||||
let e = p.expr(0, allow_union)?;
|
||||
match p.peek() {
|
||||
Tok::Eof if wrapper_height(&e) > MAX_DEPTH => Err(ParseError { msg: "formula is nested too deeply".into(), pos: 0 }),
|
||||
Tok::Eof => Ok(e),
|
||||
t => Err(p.err(format!("unexpected {}", describe(t)))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The most unary operators, parentheses and invocations on one path of `e`. The parser bounds
|
||||
/// each level's postfix wrappers by its own depth, so levels inside levels can still stack them
|
||||
/// far past [`MAX_DEPTH`]; code that maps over the tree recurses once per wrapper.
|
||||
fn wrapper_height(e: &Expr) -> usize {
|
||||
let mut most = 0;
|
||||
let mut pending = vec![(e, 0usize)];
|
||||
while let Some((e, h)) = pending.pop() {
|
||||
most = most.max(h);
|
||||
match e {
|
||||
Expr::Unary(_, a) | Expr::Paren(a) => pending.push((a, h + 1)),
|
||||
Expr::Invoke(callee, args) => {
|
||||
pending.push((callee, h + 1));
|
||||
pending.extend(args.iter().map(|a| (a, h + 1)));
|
||||
}
|
||||
Expr::Binary(_, a, b) => pending.extend([(&**a, h), (&**b, h)]),
|
||||
Expr::Call(_, args) => pending.extend(args.iter().map(|a| (a, h))),
|
||||
Expr::Array(rows) => pending.extend(rows.iter().flatten().map(|a| (a, h))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
most
|
||||
}
|
||||
|
||||
fn describe(t: &Tok) -> String {
|
||||
match t {
|
||||
Tok::RParen => "`)`".into(),
|
||||
|
||||
@@ -576,3 +576,19 @@ fn column_starting_with_at_round_trips() {
|
||||
assert_eq!(parse(&text).ok(), Some(Expr::Struct(s)), "printed as {text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrappers_stacked_across_levels_are_bounded_as_a_whole() {
|
||||
// Each level adds 40 percents inside the parser's per-level bound, so no level is too deep alone.
|
||||
let stacked = |levels: usize| {
|
||||
let mut f = "1".to_string();
|
||||
for _ in 0..levels {
|
||||
f = format!("({f}){}", "%".repeat(40));
|
||||
}
|
||||
f
|
||||
};
|
||||
assert!(parse(&stacked(5)).is_ok());
|
||||
assert!(parse(&stacked(7)).is_err(), "7 x 41 wrappers on one path");
|
||||
assert!(parse(&format!("{}1{}", "(".repeat(200), ")".repeat(200))).is_ok());
|
||||
assert!(parse(&format!("={}", vec!["A1%"; 100].join("+"))).is_ok(), "siblings are not stacked");
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ mod day_count;
|
||||
mod depreciation;
|
||||
mod securities;
|
||||
mod tvm;
|
||||
pub(crate) use tvm::cumulative_args;
|
||||
|
||||
use day_count::{coupdaybs, coupdays, coupdaysnc, coupncd, coupnum, couppcd};
|
||||
use depreciation::{amordegrc, amorlinc, db, ddb, sln, syd, vdb};
|
||||
|
||||
@@ -116,7 +116,9 @@ pub(super) fn ppmt(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(pmt_calc(rate, np, pv, fv, typ) - ipmt_calc(rate, per, np, pv, fv, typ))
|
||||
}
|
||||
|
||||
fn cumulative(a: &[Arg], principal: bool) -> R<Value> {
|
||||
/// The validated (rate, periods, pv, start, end, type) of CUMIPMT/CUMPRINC: every check that
|
||||
/// refuses a call before its period loop, shared with the lifted-call work preflight.
|
||||
pub(crate) fn cumulative_args(a: &[Arg]) -> R<(f64, f64, f64, f64, f64, f64)> {
|
||||
let (rate, np, pv) = (num(a, 0)?, num(a, 1)?, num(a, 2)?);
|
||||
let (start, end, typ) = (num(a, 3)?.trunc(), num(a, 4)?.trunc(), num(a, 5)?);
|
||||
let np_i = np.trunc();
|
||||
@@ -126,6 +128,11 @@ fn cumulative(a: &[Arg], principal: bool) -> R<Value> {
|
||||
if end - start > 1e7 || np_i > 1e15 {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
Ok((rate, np_i, pv, start, end, typ))
|
||||
}
|
||||
|
||||
fn cumulative(a: &[Arg], principal: bool) -> R<Value> {
|
||||
let (rate, np_i, pv, start, end, typ) = cumulative_args(a)?;
|
||||
let p = pmt_calc(rate, np_i, pv, 0.0, typ);
|
||||
let mut sum = 0.0;
|
||||
let mut per = start;
|
||||
|
||||
+195
-26
@@ -63,26 +63,86 @@ const SCANS: &[&str] = &["VLOOKUP", "HLOOKUP", "LOOKUP", "MATCH", "XLOOKUP", "XM
|
||||
/// `XMATCH(SEQUENCE(1048576), -SEQUENCE(1048576))` runs for hours and hangs opening the file.
|
||||
/// Past the budget the call is `#NUM!`, as MMULT is past 2e9 multiply-adds. 5e8 keeps the worst
|
||||
/// case to a few seconds and still admits 5,000 needles over a 100,000-row table.
|
||||
///
|
||||
/// The same budget covers all the work of a lifted call added up across its elements: criteria
|
||||
/// aggregates rescanning their ranges, period loops, and array results built per element.
|
||||
const LIFTED_SCAN_BUDGET: usize = 500_000_000;
|
||||
|
||||
/// The length of the longest vector a lifted call of `spec` searches per needle: 0 unless `spec`
|
||||
/// is one of the [`SCANS`] lookups and a non-lifted argument is an array.
|
||||
/// Criteria aggregates: every call tests each cell of its ranges against its criteria, so a call
|
||||
/// lifted over `n` criteria does `n` x (range cells) work, like the [`SCANS`] lookups.
|
||||
const CRITERIA: &[&str] = &["COUNTIF", "COUNTIFS", "SUMIF", "SUMIFS", "AVERAGEIF", "AVERAGEIFS", "MAXIFS", "MINIFS"];
|
||||
|
||||
/// Cumulative TVM functions that step through every period from `start_period` (argument 3) to
|
||||
/// `end_period` (argument 4) on each call.
|
||||
const PERIOD_LOOPS: &[&str] = &["CUMIPMT", "CUMPRINC"];
|
||||
|
||||
/// Budget units one period of a [`PERIOD_LOOPS`] call costs: each period computes a payment and a
|
||||
/// balance with powers, many times a scanned cell.
|
||||
const PERIOD_COST: usize = 16;
|
||||
|
||||
/// The work every lifted call of `spec` repeats over its non-lifted arguments: the longest vector
|
||||
/// a [`SCANS`] lookup searches, or every cell a [`CRITERIA`] aggregate tests. 0 for other
|
||||
/// functions.
|
||||
fn scan_len(spec: &FnSpec, args: &[Arg]) -> usize {
|
||||
if !SCANS.contains(&spec.name) {
|
||||
let scans = SCANS.contains(&spec.name);
|
||||
if !scans && !CRITERIA.contains(&spec.name) {
|
||||
return 0;
|
||||
}
|
||||
args.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| !spec.is_scalar_arg(*i))
|
||||
.filter_map(|(_, a)| match &a.value {
|
||||
Value::Array(arr) => Some(arr.rows.max(arr.cols)),
|
||||
_ => None,
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
// SUMIF and AVERAGEIF read the optional third range only at the coordinates of the first.
|
||||
let first = args.first().and_then(|a| if let Value::Array(arr) = &a.value { Some(arr.data.len()) } else { None });
|
||||
let lens = args.iter().enumerate().filter(|(i, _)| !spec.is_scalar_arg(*i)).filter_map(|(i, a)| match &a.value {
|
||||
Value::Array(arr) if scans => Some(arr.rows.max(arr.cols)),
|
||||
Value::Array(arr) if i == 2 && matches!(spec.name, "SUMIF" | "AVERAGEIF") => Some(arr.data.len().min(first.unwrap_or(usize::MAX))),
|
||||
Value::Array(arr) => Some(arr.data.len()),
|
||||
_ => None,
|
||||
});
|
||||
if scans { lens.max().unwrap_or(0) } else { lens.fold(0, usize::saturating_add) }
|
||||
}
|
||||
|
||||
/// The work of one lifted call that depends on its own (lifted) scalar arguments: the number of
|
||||
/// periods a [`PERIOD_LOOPS`] function steps through. 0 for other functions.
|
||||
fn call_cost(spec: &FnSpec, cell_args: &[Arg]) -> usize {
|
||||
if !PERIOD_LOOPS.contains(&spec.name) {
|
||||
return 0;
|
||||
}
|
||||
// Coercing numeric text reads all of it, even in a call the function then refuses, so each
|
||||
// character is charged as well: of the arguments it coerces in order, up to the first that fails.
|
||||
let mut parse = 0usize;
|
||||
for (i, a) in cell_args.iter().enumerate().take(6) {
|
||||
if let Value::Text(t) = &a.value {
|
||||
parse = parse.saturating_add(t.len());
|
||||
}
|
||||
if crate::util::num(cell_args, i).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// The function's own validation and coercion (text like "10000000" counts), so a call it
|
||||
// refuses before its loop costs nothing and no loop goes uncharged.
|
||||
let periods = crate::financial::cumulative_args(cell_args).map_or(0, |(.., start, end, _)| (end - start + 1.0) as usize);
|
||||
parse.saturating_add(periods.saturating_mul(PERIOD_COST))
|
||||
}
|
||||
|
||||
pub(crate) fn call_lifted(spec: &FnSpec, args: &[Arg], ctx: &mut dyn Ctx) -> Value {
|
||||
call_lifted_within(spec, args, ctx, LIFTED_SCAN_BUDGET)
|
||||
}
|
||||
|
||||
/// Sets the lifted arguments of `cell_args` to their elements at (`r`, `c`).
|
||||
fn element_args(spec: &FnSpec, args: &[Arg], r: usize, c: usize, cell_args: &mut [Arg]) {
|
||||
for (i, a) in args.iter().enumerate() {
|
||||
if spec.is_scalar_arg(i)
|
||||
&& let Value::Array(arr) = &a.value
|
||||
&& let Some(slot) = cell_args.get_mut(i)
|
||||
{
|
||||
let v = arr.get_broadcast(r, c);
|
||||
slot.value = if let Value::Array(inner) = &v { inner.data.first().cloned().unwrap_or(Value::Empty) } else { v };
|
||||
// A blank element of a written array argument is given, as a scalar blank is.
|
||||
slot.blank_value = !slot.from_ref && matches!(slot.value, Value::Empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`call_lifted`] with every call's work added up against `budget` (see [`LIFTED_SCAN_BUDGET`]).
|
||||
fn call_lifted_within(spec: &FnSpec, args: &[Arg], ctx: &mut dyn Ctx, budget: usize) -> Value {
|
||||
let rejected = if spec.value_args.is_empty() { None } else { reject_multi_cell_refs(spec, args) };
|
||||
let args = rejected.as_deref().unwrap_or(args);
|
||||
let mut rows = 0usize;
|
||||
@@ -101,27 +161,42 @@ pub(crate) fn call_lifted(spec: &FnSpec, args: &[Arg], ctx: &mut dyn Ctx) -> Val
|
||||
return (spec.imp)(args, ctx);
|
||||
}
|
||||
let needles = rows.saturating_mul(cols);
|
||||
if needles > MAX_CELLS || needles.saturating_mul(scan_len(spec, args)) > LIFTED_SCAN_BUDGET {
|
||||
// Every call is charged its repeated scan (at least 1), its own period loop, and the cells of
|
||||
// an array result (built in full, though only its first cell is kept), so lifting a scalar
|
||||
// argument can't multiply what one call may do into hours of work:
|
||||
// `COUNTIF(big_range, SEQUENCE(1e4))`, `CUMIPMT(0.1, 1e7, 1, 1, SEQUENCE(1e4,,1e7,0), 0)`,
|
||||
// `MUNIT(SEQUENCE(1e4,,3000,0))`.
|
||||
let per_call = scan_len(spec, args).max(1);
|
||||
let mut cell_args: Vec<Arg> = args.to_vec();
|
||||
let mut spent = needles.saturating_mul(per_call);
|
||||
if needles > MAX_CELLS || spent > budget {
|
||||
return Value::Error(CellError::Num);
|
||||
}
|
||||
let mut cell_args: Vec<Arg> = args.to_vec();
|
||||
// Period loops are known before any call runs, so a call over budget does none of them.
|
||||
if PERIOD_LOOPS.contains(&spec.name) {
|
||||
for r in 0..rows {
|
||||
for c in 0..cols {
|
||||
element_args(spec, args, r, c, &mut cell_args);
|
||||
spent = spent.saturating_add(call_cost(spec, &cell_args));
|
||||
if spent > budget {
|
||||
return Value::Error(CellError::Num);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut data = Vec::with_capacity(rows * cols);
|
||||
for r in 0..rows {
|
||||
for c in 0..cols {
|
||||
for (i, a) in args.iter().enumerate() {
|
||||
if spec.is_scalar_arg(i)
|
||||
&& let Value::Array(arr) = &a.value
|
||||
&& let Some(slot) = cell_args.get_mut(i)
|
||||
{
|
||||
let v = arr.get_broadcast(r, c);
|
||||
slot.value = if let Value::Array(inner) = &v { inner.data.first().cloned().unwrap_or(Value::Empty) } else { v };
|
||||
// A blank element of a written array argument is given, as a scalar blank is.
|
||||
slot.blank_value = !slot.from_ref && matches!(slot.value, Value::Empty);
|
||||
}
|
||||
}
|
||||
element_args(spec, args, r, c, &mut cell_args);
|
||||
let out = (spec.imp)(&cell_args, ctx);
|
||||
data.push(match out {
|
||||
Value::Array(a) => a.data.first().cloned().unwrap_or(Value::Empty),
|
||||
Value::Array(a) => {
|
||||
spent = spent.saturating_add(a.data.len());
|
||||
if spent > budget {
|
||||
return Value::Error(CellError::Num);
|
||||
}
|
||||
a.data.first().cloned().unwrap_or(Value::Empty)
|
||||
}
|
||||
v => v,
|
||||
});
|
||||
}
|
||||
@@ -206,6 +281,100 @@ mod tests {
|
||||
assert_eq!(rows_of(&out), vec![vec![nv(-1.0)]; 1000]);
|
||||
}
|
||||
|
||||
/// Security: lifting a criteria aggregate rescans its range for every criterion, so criteria
|
||||
/// x range cells past the budget is `#NUM!` before any scan runs.
|
||||
#[test]
|
||||
fn lifted_criteria_aggregates_stop_past_their_work_budget() {
|
||||
use gridcraft_core::Array;
|
||||
let tall = |n: usize| av(Array::filled(n, 1, nv(-1.0)).into());
|
||||
is_err(ev("COUNTIF", vec![tall(1_000_000), tall(501)]), CellError::Num);
|
||||
is_err(ev("SUMIF", vec![tall(1_000_000), tall(501)]), CellError::Num);
|
||||
is_err(ev("COUNTIFS", vec![tall(1_000_000), tall(501)]), CellError::Num);
|
||||
// Two ranges of 250,000 cells each count together.
|
||||
is_err(ev("SUMIFS", vec![tall(250_000), tall(250_000), tall(1001)]), CellError::Num);
|
||||
// SUMIF reads its sum range only where the criteria range has cells: a one-cell range over a
|
||||
// million-cell sum range is one cell of work per criterion.
|
||||
let one = av(Array::filled(1, 1, nv(-1.0)).into());
|
||||
assert_eq!(rows_of(&ev("SUMIF", vec![one, tall(501), tall(1_000_000)])).len(), 501);
|
||||
let counts = ev("COUNTIF", vec![av(col(&[1.0, 2.0, 2.0])), av(col(&[2.0, 1.0, 5.0]))]);
|
||||
assert_eq!(rows_of(&counts), vec![vec![nv(2.0)], vec![nv(1.0)], vec![nv(0.0)]]);
|
||||
for name in super::CRITERIA.iter().chain(super::PERIOD_LOOPS) {
|
||||
assert!(crate::lookup(name).is_some(), "{name} isn't registered");
|
||||
}
|
||||
}
|
||||
|
||||
/// Security: CUMIPMT and CUMPRINC loop over their periods once per lifted element; the
|
||||
/// periods of all elements are added up first, so an over-budget call runs none of them.
|
||||
#[test]
|
||||
fn lifted_period_loops_stop_past_their_work_budget() {
|
||||
use gridcraft_core::Array;
|
||||
let ends = |len: usize, end: f64| av(Array::filled(len, 1, nv(end)).into());
|
||||
let args = |len: usize, end: f64| vec![n(0.01), n(1e7), n(1000.0), n(1.0), ends(len, end), n(0.0)];
|
||||
// 51 elements of 1e7 periods each is past 5e8.
|
||||
is_err(ev("CUMIPMT", args(51, 1e7)), CellError::Num);
|
||||
is_err(ev("CUMPRINC", args(51, 1e7)), CellError::Num);
|
||||
// A period costs more than a scanned cell, so fewer elements are enough.
|
||||
is_err(ev("CUMIPMT", args(49, 1e7)), CellError::Num);
|
||||
// Numeric text is coerced by the function, so its periods are charged the same.
|
||||
let text_ends = av(Array::filled(51, 1, Value::text("10000000")).into());
|
||||
is_err(ev("CUMIPMT", vec![n(0.01), n(1e7), n(1000.0), n(1.0), text_ends, n(0.0)]), CellError::Num);
|
||||
let out = ev("CUMIPMT", args(2, 12.0));
|
||||
assert_eq!(rows_of(&out).len(), 2);
|
||||
assert!(matches!(rows_of(&out).first().and_then(|r| r.first()), Some(Value::Number(x)) if *x < 0.0));
|
||||
}
|
||||
|
||||
/// Security: numeric text is parsed once per lifted element even when the call is refused; the
|
||||
/// characters read are charged, so a long text repeated down a column can't cost gigabytes of parsing.
|
||||
#[test]
|
||||
fn lifted_period_loops_charge_the_text_they_parse() {
|
||||
use gridcraft_core::Array;
|
||||
// 20,000 elements x 32,000 characters is past 5e8 though no call reaches its loop (rate is negative).
|
||||
let long = Value::text(format!("{}5", "0".repeat(32_000)));
|
||||
let ends = av(Array::filled(20_000, 1, long).into());
|
||||
is_err(ev("CUMIPMT", vec![n(-0.01), n(10.0), n(1000.0), n(1.0), ends, n(0.0)]), CellError::Num);
|
||||
// A short text period costs next to nothing.
|
||||
let ends = av(Array::filled(3, 1, Value::text("12")).into());
|
||||
assert_eq!(rows_of(&ev("CUMIPMT", vec![n(0.01), n(20.0), n(1000.0), n(1.0), ends, n(0.0)])).len(), 3);
|
||||
}
|
||||
|
||||
/// Text after the first argument that fails to coerce is never read, so it is not charged: the
|
||||
/// elements keep their own errors instead of one budget `#NUM!`.
|
||||
#[test]
|
||||
fn lifted_period_loops_charge_only_the_text_they_reach() {
|
||||
use gridcraft_core::Array;
|
||||
let rates = av(Array::filled(20_000, 1, Value::text("x")).into());
|
||||
let long = Value::text(format!("{}5", "0".repeat(32_000)));
|
||||
let out = ev("CUMIPMT", vec![rates, n(10.0), n(1000.0), n(1.0), av(long), n(0.0)]);
|
||||
assert!(rows_of(&out).iter().all(|r| r.first() == Some(&Value::Error(CellError::Value))));
|
||||
}
|
||||
|
||||
/// A call the function refuses before its loop (here: end period past the period count) is
|
||||
/// not charged, so rejected elements can't push a valid one over the budget.
|
||||
#[test]
|
||||
fn lifted_period_loops_do_not_charge_rejected_calls() {
|
||||
use gridcraft_core::Array;
|
||||
let mut ends = vec![nv(1e7 + 1.0); 60];
|
||||
ends.push(nv(12.0));
|
||||
let ends = av(Array::new(ends.len(), 1, ends).unwrap_or_else(|| panic!("shape")).into());
|
||||
let out = ev("CUMIPMT", vec![n(0.01), n(1e7), n(1000.0), n(1.0), ends, n(0.0)]);
|
||||
let rows = rows_of(&out);
|
||||
assert_eq!(rows.len(), 61);
|
||||
assert!(matches!(rows.last().and_then(|r| r.first()), Some(Value::Number(x)) if *x < 0.0));
|
||||
}
|
||||
|
||||
/// Security: an array-producing function lifted over a computed array builds each element's
|
||||
/// whole result before keeping one cell; their cells count against the budget.
|
||||
#[test]
|
||||
fn lifted_array_results_count_against_the_budget() {
|
||||
use gridcraft_core::Array;
|
||||
let spec = crate::lookup("MUNIT").unwrap_or_else(|| panic!("MUNIT isn't registered"));
|
||||
let dims = |n: usize| [av(Array::filled(n, 1, nv(10.0)).into())];
|
||||
// Ten 10x10 identity matrices are 1,000 cells.
|
||||
let out = super::call_lifted_within(spec, &dims(10), &mut TestCtx::default(), 1_010);
|
||||
assert_eq!(rows_of(&out), vec![vec![nv(1.0)]; 10]);
|
||||
is_err(super::call_lifted_within(spec, &dims(11), &mut TestCtx::default(), 1_010), CellError::Num);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_params_reject_multi_cell_references() {
|
||||
let s = FnSpec { value_args: &[true, false], ..spec() };
|
||||
|
||||
@@ -55,12 +55,12 @@ fn strict_num(v: &Value) -> R<f64> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Most predictor columns LINEST, LOGEST, TREND and GROWTH accept. The normal equations are a
|
||||
/// k×k matrix inverted in O(k³), so an unbounded k (one `SEQUENCE(1,16384)` argument) allocated
|
||||
/// Most predictor columns LINEST, LOGEST, TREND and GROWTH accept. The fit works on a k×k
|
||||
/// triangle in O(k³), so an unbounded k (one `SEQUENCE(1,16384)` argument) allocated
|
||||
/// gigabytes and could abort the process.
|
||||
pub(super) const MAX_REGRESSORS: usize = 255;
|
||||
|
||||
/// Most multiply-adds (rows × k²) a regression may spend building its normal equations.
|
||||
/// Most multiply-adds (rows × k²) a regression may spend on its factorisation.
|
||||
const MAX_REGRESSION_WORK: usize = 1_000_000_000;
|
||||
|
||||
/// Rejects regressions too large to fit: more than [`MAX_REGRESSORS`] predictors is `#VALUE!`,
|
||||
@@ -121,59 +121,136 @@ struct Fit {
|
||||
ssresid: f64,
|
||||
}
|
||||
|
||||
/// Inverts a symmetric positive semi-definite matrix by Gauss–Jordan on the diagonal, dropping
|
||||
/// (zeroing) collinear variables. Returns the inverse and the dropped flags.
|
||||
fn sym_inverse(m: &[Vec<f64>]) -> (Vec<Vec<f64>>, Vec<bool>) {
|
||||
let p = m.len();
|
||||
let mut a: Vec<Vec<f64>> = m.to_vec();
|
||||
let mut inv: Vec<Vec<f64>> = (0..p).map(|i| (0..p).map(|j| if i == j { 1.0 } else { 0.0 }).collect()).collect();
|
||||
let mut dropped = vec![false; p];
|
||||
let diag: Vec<f64> = (0..p).map(|i| m.get(i).and_then(|r| r.get(i)).copied().unwrap_or(0.0).abs()).collect();
|
||||
for j in 0..p {
|
||||
let piv = a.get(j).and_then(|r| r.get(j)).copied().unwrap_or(0.0);
|
||||
let scale = diag.get(j).copied().unwrap_or(0.0);
|
||||
if piv.abs() <= 1e-12 * scale || piv.abs() < 1e-300 {
|
||||
if let Some(d) = dropped.get_mut(j) {
|
||||
*d = true;
|
||||
}
|
||||
for row in a.iter_mut() {
|
||||
if let Some(v) = row.get_mut(j) {
|
||||
*v = 0.0;
|
||||
}
|
||||
}
|
||||
if let Some(r) = a.get_mut(j) {
|
||||
r.iter_mut().for_each(|v| *v = 0.0);
|
||||
}
|
||||
if let Some(r) = inv.get_mut(j) {
|
||||
r.iter_mut().for_each(|v| *v = 0.0);
|
||||
}
|
||||
/// Euclidean norm without intermediate overflow or underflow.
|
||||
fn norm2(v: &[f64]) -> f64 {
|
||||
let m = v.iter().fold(0.0f64, |m, x| m.max(x.abs()));
|
||||
if m == 0.0 || !m.is_finite() {
|
||||
return m;
|
||||
}
|
||||
m * v.iter().map(|x| (x / m) * (x / m)).sum::<f64>().sqrt()
|
||||
}
|
||||
|
||||
/// Result of [`qr_solve`]: the least-squares solution on the kept columns, in the caller's
|
||||
/// column scale, and what the standard errors need.
|
||||
struct QrFit {
|
||||
coef: Vec<f64>,
|
||||
dropped: Vec<bool>,
|
||||
/// Residual sum of squares of the fit.
|
||||
ssresid: f64,
|
||||
/// Diagonal of `(X'X)^-1` over the kept columns (0 for dropped ones).
|
||||
diag: Vec<f64>,
|
||||
/// `xm' (X'X)^-1 xm` over the kept columns.
|
||||
quad: f64,
|
||||
}
|
||||
|
||||
/// Least squares by Householder QR on unit-norm columns, instead of the normal equations, whose
|
||||
/// condition number is the square of the data's. Columns are taken left to right; one whose part
|
||||
/// outside the span of the kept columns is under 1e-6 of its own length (the 1e-12 relative
|
||||
/// pivot of the earlier normal-equation solver, on squares) is collinear: it is dropped, with
|
||||
/// coefficient and standard error 0, as Excel does.
|
||||
///
|
||||
/// `constant[j]` says column `j` was constant before centring. Centring it leaves rounding
|
||||
/// residue, not 0 (`[0.1; 3]` centres to about -1.4e-17): it is collinear with the intercept, so
|
||||
/// it is dropped instead of being scaled up to unit length. Whether a column is constant is
|
||||
/// decided on its raw values, not by comparing the centred length with the offset: a predictor
|
||||
/// like `[1e13, 1e13 + 1, 1e13 + 2]` is far smaller than its offset and still varies.
|
||||
fn qr_solve(mut cols: Vec<Vec<f64>>, mut y: Vec<f64>, xm: &[f64], constant: &[bool]) -> QrFit {
|
||||
let n = y.len();
|
||||
let k = cols.len();
|
||||
let mut scale = vec![1.0; k];
|
||||
let mut dropped = vec![false; k];
|
||||
// Householder vectors (unit norm, acting on rows `at..n`) and the columns of R.
|
||||
let mut refl: Vec<(usize, Vec<f64>)> = Vec::new();
|
||||
let mut r_cols: Vec<Vec<f64>> = Vec::new();
|
||||
let mut kept: Vec<usize> = Vec::new();
|
||||
for (j, col) in cols.iter_mut().enumerate() {
|
||||
let len = norm2(col);
|
||||
if constant.get(j).copied().unwrap_or(false) || !(len > 1e-300 && len.is_finite()) {
|
||||
dropped[j] = true;
|
||||
continue;
|
||||
}
|
||||
let arow: Vec<f64> = a.get(j).map(|r| r.iter().map(|v| v / piv).collect()).unwrap_or_default();
|
||||
let irow: Vec<f64> = inv.get(j).map(|r| r.iter().map(|v| v / piv).collect()).unwrap_or_default();
|
||||
for i in 0..p {
|
||||
if i == j {
|
||||
continue;
|
||||
}
|
||||
let f = a.get(i).and_then(|r| r.get(j)).copied().unwrap_or(0.0);
|
||||
if f == 0.0 {
|
||||
continue;
|
||||
}
|
||||
if let Some(r) = a.get_mut(i) {
|
||||
r.iter_mut().zip(&arow).for_each(|(v, w)| *v -= f * w);
|
||||
}
|
||||
if let Some(r) = inv.get_mut(i) {
|
||||
r.iter_mut().zip(&irow).for_each(|(v, w)| *v -= f * w);
|
||||
}
|
||||
col.iter_mut().for_each(|v| *v /= len);
|
||||
scale[j] = len;
|
||||
for (at, v) in &refl {
|
||||
apply_reflector(*at, v, col);
|
||||
}
|
||||
if let Some(r) = a.get_mut(j) {
|
||||
*r = arow;
|
||||
let at = refl.len();
|
||||
let tail = col.get(at..).unwrap_or(&[]);
|
||||
let alpha = norm2(tail);
|
||||
if alpha * alpha <= 1e-12 {
|
||||
dropped[j] = true;
|
||||
continue;
|
||||
}
|
||||
if let Some(r) = inv.get_mut(j) {
|
||||
*r = irow;
|
||||
let sign = if tail.first().is_some_and(|t| *t < 0.0) { -1.0 } else { 1.0 };
|
||||
let mut v: Vec<f64> = tail.to_vec();
|
||||
if let Some(h) = v.first_mut() {
|
||||
*h += sign * alpha;
|
||||
}
|
||||
let vn = norm2(&v);
|
||||
v.iter_mut().for_each(|x| *x /= vn);
|
||||
let mut rc: Vec<f64> = col.get(..at).unwrap_or(&[]).to_vec();
|
||||
rc.push(-sign * alpha);
|
||||
r_cols.push(rc);
|
||||
refl.push((at, v));
|
||||
kept.push(j);
|
||||
}
|
||||
for (at, v) in &refl {
|
||||
apply_reflector(*at, v, &mut y);
|
||||
}
|
||||
let r = kept.len();
|
||||
// Back substitution R c = (Q'y)[..r], then undo the column scaling.
|
||||
let mut c = vec![0.0; r];
|
||||
for i in (0..r).rev() {
|
||||
let mut t = y.get(i).copied().unwrap_or(0.0);
|
||||
for jj in i + 1..r {
|
||||
t -= r_cols.get(jj).and_then(|rc| rc.get(i)).copied().unwrap_or(0.0) * c.get(jj).copied().unwrap_or(0.0);
|
||||
}
|
||||
if let Some(ci) = c.get_mut(i) {
|
||||
*ci = t / r_cols.get(i).and_then(|rc| rc.get(i)).copied().unwrap_or(f64::NAN);
|
||||
}
|
||||
}
|
||||
(inv, dropped)
|
||||
let ssresid = y.get(r..n).unwrap_or(&[]).iter().map(|v| v * v).sum::<f64>();
|
||||
// R^-1 (upper triangular), row i of the inverse at rinv[i].
|
||||
let rij = |i: usize, j: usize| r_cols.get(j).and_then(|rc| rc.get(i)).copied().unwrap_or(0.0);
|
||||
let mut rinv = vec![vec![0.0; r]; r];
|
||||
for t in 0..r {
|
||||
for i in (0..=t).rev() {
|
||||
let mut v = if i == t { 1.0 } else { 0.0 };
|
||||
for jj in i + 1..=t {
|
||||
v -= rij(i, jj) * rinv.get(jj).and_then(|row| row.get(t)).copied().unwrap_or(0.0);
|
||||
}
|
||||
if let Some(cell) = rinv.get_mut(i).and_then(|row| row.get_mut(t)) {
|
||||
*cell = v / rij(i, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut coef = vec![0.0; k];
|
||||
let mut diag = vec![0.0; k];
|
||||
for (i, &j) in kept.iter().enumerate() {
|
||||
let s = scale.get(j).copied().unwrap_or(1.0);
|
||||
if let Some(v) = coef.get_mut(j) {
|
||||
*v = c.get(i).copied().unwrap_or(0.0) / s;
|
||||
}
|
||||
let row = rinv.get(i);
|
||||
if let Some(v) = diag.get_mut(j) {
|
||||
*v = row.map_or(0.0, |rw| rw.iter().map(|x| x * x).sum::<f64>()) / (s * s);
|
||||
}
|
||||
}
|
||||
// xm' (S R'R S)^-1 xm = |R^-T S^-1 xm|².
|
||||
let w: Vec<f64> = kept.iter().map(|&j| xm.get(j).copied().unwrap_or(0.0) / scale.get(j).copied().unwrap_or(1.0)).collect();
|
||||
let quad = (0..r)
|
||||
.map(|t| {
|
||||
(0..=t).map(|i| rinv.get(i).and_then(|row| row.get(t)).copied().unwrap_or(0.0) * w.get(i).copied().unwrap_or(0.0)).sum::<f64>().powi(2)
|
||||
})
|
||||
.sum::<f64>();
|
||||
QrFit { coef, dropped, ssresid, diag, quad }
|
||||
}
|
||||
|
||||
/// Applies the Householder reflection `I - 2vv'` on rows `at..` of `x`.
|
||||
fn apply_reflector(at: usize, v: &[f64], x: &mut [f64]) {
|
||||
let Some(tail) = x.get_mut(at..) else { return };
|
||||
let dot: f64 = v.iter().zip(tail.iter()).map(|(a, b)| a * b).sum();
|
||||
tail.iter_mut().zip(v).for_each(|(t, a)| *t -= 2.0 * dot * a);
|
||||
}
|
||||
|
||||
fn regress(y: &[f64], x: &[Vec<f64>], k: usize, constant: bool) -> R<Fit> {
|
||||
@@ -189,35 +266,22 @@ fn regress(y: &[f64], x: &[Vec<f64>], k: usize, constant: bool) -> R<Fit> {
|
||||
} else {
|
||||
(vec![0.0; k], 0.0)
|
||||
};
|
||||
// Normal equations on (centred) data.
|
||||
let mut xtx = vec![vec![0.0; k]; k];
|
||||
let mut xty = vec![0.0; k];
|
||||
for (row, yv) in x.iter().zip(y) {
|
||||
let d: Vec<f64> = (0..k).map(|j| row.get(j).copied().unwrap_or(0.0) - xm.get(j).copied().unwrap_or(0.0)).collect();
|
||||
let yd = yv - ym;
|
||||
for (i, di) in d.iter().enumerate() {
|
||||
if let Some(t) = xty.get_mut(i) {
|
||||
*t += di * yd;
|
||||
}
|
||||
if let Some(r) = xtx.get_mut(i) {
|
||||
for (j, dj) in d.iter().enumerate() {
|
||||
if let Some(v) = r.get_mut(j) {
|
||||
*v += di * dj;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let (inv, dropped) = sym_inverse(&xtx);
|
||||
let coef: Vec<f64> = (0..k).map(|i| inv.get(i).map(|r| r.iter().zip(&xty).map(|(a, b)| a * b).sum()).unwrap_or(0.0)).collect();
|
||||
// (Centred) predictor columns and response.
|
||||
let cols: Vec<Vec<f64>> =
|
||||
(0..k).map(|j| x.iter().map(|r| r.get(j).copied().unwrap_or(0.0) - xm.get(j).copied().unwrap_or(0.0)).collect()).collect();
|
||||
let yc: Vec<f64> = y.iter().map(|v| v - ym).collect();
|
||||
let sstot: f64 = if constant { yc.iter().map(|v| v * v).sum() } else { y.iter().map(|v| v * v).sum() };
|
||||
// Only centring leaves rounding residue behind; without an intercept a constant column is data.
|
||||
// Constant means every raw value is the same double: distinct values, however close, vary.
|
||||
let constant_cols: Vec<bool> = (0..k)
|
||||
.map(|j| {
|
||||
let mut col = x.iter().map(|r| r.get(j).copied().unwrap_or(0.0));
|
||||
let first = col.next();
|
||||
constant && first.is_some_and(|f| col.all(|v| v == f))
|
||||
})
|
||||
.collect();
|
||||
let QrFit { coef, dropped, ssresid, diag, quad } = qr_solve(cols, yc, &xm, &constant_cols);
|
||||
let b = if constant { ym - coef.iter().zip(&xm).map(|(c, m)| c * m).sum::<f64>() } else { 0.0 };
|
||||
let mut ssresid = 0.0;
|
||||
let mut sstot = 0.0;
|
||||
for (row, yv) in x.iter().zip(y) {
|
||||
let pred = b + coef.iter().zip(row).map(|(c, v)| c * v).sum::<f64>();
|
||||
ssresid += (yv - pred).powi(2);
|
||||
sstot += if constant { (yv - ym).powi(2) } else { yv * yv };
|
||||
}
|
||||
let used = dropped.iter().filter(|d| !**d).count() + usize::from(constant);
|
||||
let df = nf - used as f64;
|
||||
let ssreg = sstot - ssresid;
|
||||
@@ -230,20 +294,9 @@ fn regress(y: &[f64], x: &[Vec<f64>], k: usize, constant: bool) -> R<Fit> {
|
||||
_ => Err(CellError::Num),
|
||||
};
|
||||
let s2 = sey2.unwrap_or(f64::NAN);
|
||||
let se: Vec<f64> = (0..k)
|
||||
.map(|i| if dropped.get(i).copied().unwrap_or(true) { 0.0 } else { (inv.get(i).and_then(|r| r.get(i)).copied().unwrap_or(0.0) * s2).sqrt() })
|
||||
.collect();
|
||||
let se_b = if constant {
|
||||
let mut q = 0.0;
|
||||
for (i, mi) in xm.iter().enumerate() {
|
||||
for (j, mj) in xm.iter().enumerate() {
|
||||
q += mi * mj * inv.get(i).and_then(|r| r.get(j)).copied().unwrap_or(0.0);
|
||||
}
|
||||
}
|
||||
Some((s2 * (1.0 / nf + q)).sqrt())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let se: Vec<f64> =
|
||||
(0..k).map(|i| if dropped.get(i).copied().unwrap_or(true) { 0.0 } else { (diag.get(i).copied().unwrap_or(0.0) * s2).sqrt() }).collect();
|
||||
let se_b = if constant { Some((s2 * (1.0 / nf + quad)).sqrt()) } else { None };
|
||||
Ok(Fit { coef, b, se, se_b, r2, sey, f, df, ssreg, ssresid })
|
||||
}
|
||||
|
||||
|
||||
@@ -433,6 +433,185 @@ fn linest_family() {
|
||||
is_err(ev("LINEST", vec![c(&[1.0, 2.0]), c(&[1.0, 2.0, 3.0])]), CellError::Ref);
|
||||
}
|
||||
|
||||
/// LINEST on columns of predictors, `stats` on, as rows of numbers (`None` for an error cell).
|
||||
fn linest_rows(y: &[f64], xs: &[Vec<f64>], constant: bool) -> Vec<Vec<Option<f64>>> {
|
||||
let x = arr((0..y.len()).map(|i| xs.iter().map(|col| nv(col[i])).collect()).collect());
|
||||
let r = rows_of(&ev("LINEST", vec![c(y), av(x), b(constant), b(true)]));
|
||||
r.iter().map(|row| row.iter().map(Value::as_f64).collect()).collect()
|
||||
}
|
||||
|
||||
fn rel_close(got: Option<f64>, want: f64, tol: f64, what: &str) {
|
||||
let got = got.unwrap_or(f64::NAN);
|
||||
assert!((got - want).abs() <= tol * want.abs().max(1e-300), "{what}: got {got:e}, want {want:e}, rel tol {tol:e}");
|
||||
}
|
||||
|
||||
// B2: LINEST solved the normal equations, whose condition number is the square of the data's.
|
||||
// Expected values below come from exact rational arithmetic: the normal equations solved with
|
||||
// Python `fractions.Fraction` (no rounding at all), rounded once to f64 (script in the PR text).
|
||||
#[test]
|
||||
fn linest_ill_conditioned_polynomial_is_exact() {
|
||||
// y = 1 + x + x² + x³ + x⁴ + x⁵ on x = 1..12: every coefficient is exactly 1. The normal
|
||||
// equations were off by up to 2.6e-6.
|
||||
let xs: Vec<f64> = (1..=12).map(f64::from).collect();
|
||||
let ys: Vec<f64> = xs.iter().map(|x| (0..=5).map(|j| x.powi(j)).sum()).collect();
|
||||
let cols: Vec<Vec<f64>> = (1..=5).map(|j| xs.iter().map(|x| x.powi(j)).collect()).collect();
|
||||
let r = linest_rows(&ys, &cols, true);
|
||||
for (i, v) in r[0].iter().enumerate() {
|
||||
rel_close(*v, 1.0, 1e-9, &format!("coefficient {i}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linest_large_offset_regressors_recover_the_line() {
|
||||
// y = 3x + 7 exactly with x = 1e6 + i and regressors [x, x²]: the true fit has x² coefficient
|
||||
// 0, x coefficient 3, intercept 7. The normal equations returned 2.99976 and 251.14.
|
||||
let xs: Vec<f64> = (0..12).map(|i| 1_000_000.0 + f64::from(i)).collect();
|
||||
let ys: Vec<f64> = xs.iter().map(|x| 3.0 * x + 7.0).collect();
|
||||
let r = linest_rows(&ys, &[xs.clone(), xs.iter().map(|x| x * x).collect()], true);
|
||||
assert!(r[0][0].unwrap().abs() < 1e-12, "x² coefficient {:?}", r[0][0]);
|
||||
rel_close(r[0][1], 3.0, 1e-9, "x coefficient");
|
||||
// The intercept subtracts 3e6 from 3e6 + 7 and carries the x² coefficient times 1e12, so
|
||||
// it is good to about 1e-4 in absolute terms, not better.
|
||||
assert!((r[0][2].unwrap() - 7.0).abs() < 1e-3, "intercept {:?}", r[0][2]);
|
||||
// One regressor: exact.
|
||||
let one = linest_rows(&ys, std::slice::from_ref(&xs), true);
|
||||
rel_close(one[0][0], 3.0, 1e-12, "slope");
|
||||
rel_close(one[0][1], 7.0, 1e-9, "intercept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linest_noisy_fits_match_exact_rational_least_squares() {
|
||||
// Two predictors with noise, constant fitted. Order of each row: x2, x1, intercept.
|
||||
let x1 = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
|
||||
let x2 = [2.0, 1.0, 4.0, 3.0, 7.0, 5.0, 9.0, 6.0];
|
||||
let y = [5.0, 6.0, 11.0, 12.0, 20.0, 19.0, 30.0, 28.0];
|
||||
let r = linest_rows(&y, &[x1.to_vec(), x2.to_vec()], true);
|
||||
let (coef, se) = ([1.4270152505446623, 2.452069716775599, -1.2592592592592593], [0.3376442444771937, 0.3679392851360571, 1.0847489958915801]);
|
||||
for i in 0..3 {
|
||||
rel_close(r[0][i], coef[i], 1e-12, "coef");
|
||||
rel_close(r[1][i], se[i], 1e-12, "se");
|
||||
}
|
||||
rel_close(r[2][0], 0.985070139277778, 1e-12, "r2");
|
||||
rel_close(r[2][1], 1.367057173604725, 1e-12, "sey");
|
||||
rel_close(r[3][0], 164.94965318256004, 1e-11, "F");
|
||||
assert_eq!(r[3][1], Some(5.0));
|
||||
rel_close(r[4][0], 616.5307734204792, 1e-12, "ssreg");
|
||||
rel_close(r[4][1], 9.344226579520697, 1e-11, "ssresid");
|
||||
assert_eq!(r[2][2], None);
|
||||
// const = FALSE: through the origin, R² and F from the uncentred sums.
|
||||
let r = linest_rows(&y, &[x1.to_vec(), x2.to_vec()], false);
|
||||
rel_close(r[0][0], 1.3529411764705883, 1e-12, "coef");
|
||||
rel_close(r[0][1], 2.303921568627451, 1e-12, "coef");
|
||||
assert_eq!(r[0][2], Some(0.0));
|
||||
rel_close(r[1][0], 0.34102994374364876, 1e-12, "se");
|
||||
rel_close(r[1][1], 0.35495521934550084, 1e-12, "se");
|
||||
assert_eq!(r[1][2], None);
|
||||
rel_close(r[2][0], 0.995718966041848, 1e-12, "r2");
|
||||
rel_close(r[2][1], 1.4061024795535124, 1e-12, "sey");
|
||||
rel_close(r[3][0], 697.7652892561983, 1e-11, "F");
|
||||
assert_eq!(r[3][1], Some(6.0));
|
||||
rel_close(r[4][0], 2759.1372549019607, 1e-12, "ssreg");
|
||||
rel_close(r[4][1], 11.862745098039216, 1e-11, "ssresid");
|
||||
// Cubic and quintic with noise on x = 1..12; order x^k .. x, intercept.
|
||||
let xs: Vec<f64> = (1..=12).map(f64::from).collect();
|
||||
let noise = [3.0, -2.0, 4.0, -1.0, 2.0, -3.0, 1.0, -4.0, 2.0, 3.0, -2.0, 1.0];
|
||||
let cub: Vec<f64> = xs.iter().zip(noise).map(|(x, e)| 1.0 + x + x * x + x.powi(3) + e).collect();
|
||||
let r = linest_rows(&cub, &(1..=3).map(|j| xs.iter().map(|x| x.powi(j)).collect()).collect::<Vec<Vec<f64>>>(), true);
|
||||
let coef = [0.9955969955969955, 1.1452991452991452, -0.33514633514633513, 4.373737373737374];
|
||||
let se = [0.02755463331301788, 0.5434124199147191, 3.102549978874372, 4.851885058051787];
|
||||
for i in 0..4 {
|
||||
rel_close(r[0][i], coef[i], 1e-11, "cubic coef");
|
||||
rel_close(r[1][i], se[i], 1e-10, "cubic se");
|
||||
}
|
||||
rel_close(r[2][0], 0.9999837292113533, 1e-12, "cubic r2");
|
||||
rel_close(r[4][1], 70.35586635586635, 1e-9, "cubic ssresid");
|
||||
let quint: Vec<f64> = xs.iter().zip(noise).map(|(x, e)| (0..=5).map(|j| x.powi(j)).sum::<f64>() + 50.0 * e).collect();
|
||||
let r = linest_rows(&quint, &(1..=5).map(|j| xs.iter().map(|x| x.powi(j)).collect()).collect::<Vec<Vec<f64>>>(), true);
|
||||
let coef = [0.9330693815987934, 3.022273070067188, -20.97398190045249, 108.20489681886741, -250.24940010969422, 271.45454545454544];
|
||||
let se = [0.20046539191103213, 6.538253746916523, 78.39635356777087, 421.2608646012634, 976.3504976093745, 736.5866411764556];
|
||||
for i in 0..6 {
|
||||
rel_close(r[0][i], coef[i], 1e-8, "quintic coef");
|
||||
rel_close(r[1][i], se[i], 1e-8, "quintic se");
|
||||
}
|
||||
rel_close(r[2][1], 168.58174051770763, 1e-9, "quintic sey");
|
||||
rel_close(r[4][1], 170518.81941587824, 1e-7, "quintic ssresid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linest_drops_collinear_columns() {
|
||||
// x2 = 2·x1: the later column is dropped (coefficient and standard error 0), the fit is the
|
||||
// one on x1 alone, and the degrees of freedom count only the kept column.
|
||||
let x1 = [1.0, 2.0, 3.0, 4.0, 5.0];
|
||||
let y = [2.0, 4.0, 5.0, 4.0, 5.0];
|
||||
let x2: Vec<f64> = x1.iter().map(|v| 2.0 * v).collect();
|
||||
let r = linest_rows(&y, &[x1.to_vec(), x2], true);
|
||||
// Exact: slope 0.6, intercept 2.2, ssresid 2.4, ssreg 3.6, df 3 (Fraction arithmetic).
|
||||
assert_eq!(r[0][0], Some(0.0));
|
||||
rel_close(r[0][1], 0.6, 1e-12, "slope");
|
||||
rel_close(r[0][2], 2.2, 1e-12, "intercept");
|
||||
assert_eq!(r[1][0], Some(0.0));
|
||||
rel_close(r[1][1], (0.8f64 / 10.0).sqrt(), 1e-12, "se slope");
|
||||
assert_eq!(r[3][1], Some(3.0));
|
||||
rel_close(r[4][0], 3.6, 1e-12, "ssreg");
|
||||
rel_close(r[4][1], 2.4, 1e-12, "ssresid");
|
||||
// A constant column is collinear with the intercept and dropped as well.
|
||||
let r = linest_rows(&y, &[vec![7.0; 5], x1.to_vec()], true);
|
||||
assert_eq!(r[0][1], Some(0.0));
|
||||
assert_eq!(r[1][1], Some(0.0));
|
||||
rel_close(r[0][0], 0.6, 1e-12, "slope");
|
||||
rel_close(r[0][2], 2.2, 1e-12, "intercept");
|
||||
// A constant column whose mean does not centre to exactly 0 (the mean of three 0.1 is
|
||||
// 0.10000000000000002) is just as collinear with the intercept: dropped, not scaled up to a
|
||||
// unit vector. Some length hits a rounding mean for each constant.
|
||||
for c in [0.1, 1.1, 1e6 + 0.3] {
|
||||
for n in 3..=12 {
|
||||
let xs: Vec<f64> = (1..=n).map(f64::from).collect();
|
||||
let ys: Vec<f64> = (1..=n).map(|i| f64::from(i * i % 7) + 0.5 * f64::from(i)).collect();
|
||||
let r = linest_rows(&ys, &[vec![c; n as usize], xs], true);
|
||||
assert_eq!(r[0][1], Some(0.0), "coefficient of the constant {c}, n={n}");
|
||||
assert_eq!(r[1][1], Some(0.0), "standard error of the constant {c}, n={n}");
|
||||
assert_eq!(r[3][1], Some(f64::from(n) - 2.0), "df with the constant {c}, n={n}");
|
||||
}
|
||||
}
|
||||
// A column that merely sits far from zero is not noise and is kept.
|
||||
let off: Vec<f64> = x1.iter().map(|v| 1e9 + v).collect();
|
||||
let r = linest_rows(&y, &[off], true);
|
||||
rel_close(r[0][0], 0.6, 1e-6, "slope on an offset column");
|
||||
assert_eq!(r[3][1], Some(3.0));
|
||||
// Distinct values far from zero, whose spread is below 1e-12 of the offset, still vary.
|
||||
let r = linest_rows(&[0.0, 1.0, 2.0], &[vec![1e13, 1e13 + 1.0, 1e13 + 2.0]], true);
|
||||
rel_close(r[0][0], 1.0, 1e-9, "slope on a predictor with a 1e13 offset");
|
||||
assert_eq!(r[3][1], Some(1.0), "df: the predictor is kept");
|
||||
// Same at 1e15, where the spread is only a few ulps of the offset: still distinct doubles.
|
||||
let x15 = [1e15, 1e15 + 1.0, 1e15 + 2.0];
|
||||
let r = linest_rows(&[0.0, 1.0, 2.0], &[x15.to_vec()], true);
|
||||
rel_close(r[0][0], 1.0, 1e-9, "slope on a predictor with a 1e15 offset");
|
||||
rel_close(r[0][1], -1e15, 1e-9, "intercept on a predictor with a 1e15 offset");
|
||||
assert_eq!(r[3][1], Some(1.0), "df: the 1e15 predictor is kept");
|
||||
assert!(r[4][1].is_some_and(|v| v.abs() < 1e-9), "ssresid: the 1e15 line is exact, got {:?}", r[4][1]);
|
||||
let fitted = rows_of(&ev("TREND", vec![c(&[0.0, 1.0, 2.0]), c(&x15)]));
|
||||
close(fitted[2][0].clone(), 2.0);
|
||||
// More predictors than observations: the surplus columns drop, no panic.
|
||||
let r = linest_rows(&[1.0, 2.0], &[vec![1.0, 2.0], vec![2.0, 1.0], vec![0.0, 5.0]], true);
|
||||
assert_eq!(r[0].len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logest_is_linest_of_ln_y() {
|
||||
// y = 5·2^x: m = 2, b = 5.
|
||||
let x = [0.0, 1.0, 2.0, 3.0, 4.0];
|
||||
let y: Vec<f64> = x.iter().map(|v| 5.0 * 2f64.powf(*v)).collect();
|
||||
let l = rows_of(&ev("LOGEST", vec![c(&y), c(&x)]));
|
||||
close_tol(l[0][0].clone(), 2.0, 1e-12);
|
||||
close_tol(l[0][1].clone(), 5.0, 1e-12);
|
||||
// The statistics are those of the fit on ln y, untransformed.
|
||||
let ln: Vec<f64> = y.iter().map(|v| v.ln()).collect();
|
||||
let ls = rows_of(&ev("LOGEST", vec![c(&y), c(&x), b(true), b(true)]));
|
||||
let ns = rows_of(&ev("LINEST", vec![c(&ln), c(&x), b(true), b(true)]));
|
||||
assert_eq!(ls[1..], ns[1..]);
|
||||
close_tol(ls[0][0].clone(), ns[0][0].as_f64().unwrap().exp(), 1e-14);
|
||||
}
|
||||
|
||||
// #2177: one predictor row of 16,384 columns built three 16,384² matrices (6 GB) and an O(k³)
|
||||
// inversion; a wider TOROW aborted the process. It is now refused before allocating.
|
||||
#[test]
|
||||
|
||||
@@ -53,6 +53,15 @@ impl Remote {
|
||||
self.conn = 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()? {
|
||||
// The token goes over plain TCP, so only to this machine: a network peer could read it,
|
||||
// and the app's control port listens on loopback only.
|
||||
if !sa.ip().is_loopback() {
|
||||
last = std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
format!("{sa} is not a loopback address: the control token is sent only to 127.0.0.1 or ::1"),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
match TcpStream::connect_timeout(&sa, Duration::from_secs(2)) {
|
||||
Ok(s) => {
|
||||
s.set_nodelay(true).ok();
|
||||
|
||||
@@ -482,6 +482,9 @@ fn remote_backend_over_tcp() {
|
||||
drop(s);
|
||||
handle.join().unwrap();
|
||||
assert!(Remote::connect_with_token("127.0.0.1:1", "x".into()).is_err());
|
||||
// A network address is refused before anything, the token included, is sent.
|
||||
let err = Remote::connect_with_token("192.0.2.1:7979", "x".into()).err().unwrap();
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied, "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -668,6 +668,9 @@ pub enum ShapeKind {
|
||||
/// A freehand ink stroke; `Shape::text` holds the points as `x,y;x,y;…` relative to the
|
||||
/// anchor (sheet points), `Shape::line` the ink colour and `fill` unused.
|
||||
Ink,
|
||||
/// Decorative text (`insert.wordArt`): a transparent shape whose `Shape::text` is drawn with
|
||||
/// the large, filled/outlined/shadowed text properties of `Shape::format`.
|
||||
WordArt,
|
||||
/// An equation: `Shape::text` holds its LaTeX source, `Shape::line` its ink colour
|
||||
/// (`Color::Auto` for the sheet's text colour) and `Shape::math` its cached layout.
|
||||
Equation,
|
||||
@@ -1026,6 +1029,8 @@ pub struct ShapeFormat {
|
||||
pub text_color: Option<Color>,
|
||||
/// Text outline (`a:rPr/a:ln`): colour and width in points.
|
||||
pub text_outline: Option<(Color, f32)>,
|
||||
/// Text drop shadow colour (`a:rPr/a:effectLst/a:outerShdw`); `None` is no shadow.
|
||||
pub text_shadow: Option<Color>,
|
||||
/// `None`: the shape's default (left for text boxes, centred for other shapes).
|
||||
pub align: Option<ShapeTextAlign>,
|
||||
/// `None`: the shape's default (top for text boxes, middle for other shapes).
|
||||
|
||||
+353
-134
@@ -19,7 +19,10 @@ mod symbol;
|
||||
|
||||
pub use symbol::{symbol_code, symbol_width};
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::fmt::Write as _;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
/// RGB colour, 0–255 per channel.
|
||||
pub type Rgb = [u8; 3];
|
||||
@@ -322,6 +325,31 @@ fn n(v: f32) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends `v / 255` with at most three decimals, exactly what `n(v as f32 / 255.0)` gives, but
|
||||
/// without a float round trip or an allocation per channel.
|
||||
fn push_unit(s: &mut String, v: u8) {
|
||||
let m = (u32::from(v) * 1000 + 127) / 255;
|
||||
match m {
|
||||
0 => s.push('0'),
|
||||
1000 => s.push('1'),
|
||||
_ => {
|
||||
let _ = write!(s, "0.{m:03}");
|
||||
while s.ends_with('0') {
|
||||
s.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends a colour operator line without the trailing newline: `r g b rg` (fill) or `r g b RG`.
|
||||
fn push_rgb(s: &mut String, c: Rgb, stroke: bool) {
|
||||
for v in c {
|
||||
push_unit(s, v);
|
||||
s.push(' ');
|
||||
}
|
||||
s.push_str(if stroke { "RG" } else { "rg" });
|
||||
}
|
||||
|
||||
/// True when every value is finite (NaN and infinities are rejected).
|
||||
fn finite(v: &[f32]) -> bool {
|
||||
v.iter().all(|x| x.is_finite())
|
||||
@@ -348,16 +376,44 @@ fn dash_values(dash: &[f32]) -> String {
|
||||
d.iter().map(|v| n(*v)).collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
fn rgb_op(c: Rgb, stroke: bool) -> String {
|
||||
let f = |v: u8| n(v as f32 / 255.0);
|
||||
format!("{} {} {} {}", f(c[0]), f(c[1]), f(c[2]), if stroke { "RG" } else { "rg" })
|
||||
}
|
||||
|
||||
struct ImageData {
|
||||
width: u32,
|
||||
height: u32,
|
||||
rgb: Vec<u8>,
|
||||
/// `None` when every pixel is fully opaque (no soft mask is written).
|
||||
alpha: Option<Vec<u8>>,
|
||||
/// Hash of all of the above, to find duplicates quickly; equality still compares the bytes.
|
||||
hash: u64,
|
||||
}
|
||||
|
||||
impl ImageData {
|
||||
fn new(width: u32, height: u32, rgb: Vec<u8>, alpha: Option<Vec<u8>>) -> ImageData {
|
||||
let mut h = DefaultHasher::new();
|
||||
width.hash(&mut h);
|
||||
height.hash(&mut h);
|
||||
rgb.hash(&mut h);
|
||||
alpha.hash(&mut h);
|
||||
ImageData { width, height, rgb, alpha, hash: h.finish() }
|
||||
}
|
||||
|
||||
/// Same pixels (a hash match alone is never trusted).
|
||||
fn same(&self, o: &ImageData) -> bool {
|
||||
self.hash == o.hash && self.width == o.width && self.height == o.height && self.rgb == o.rgb && self.alpha == o.alpha
|
||||
}
|
||||
}
|
||||
|
||||
/// The part of the graphics state the writer tracks so that a style operator is only emitted
|
||||
/// when it changes. `None` = not set yet on this page (or since the last restore that undid it).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
struct Style {
|
||||
fill: Option<Rgb>,
|
||||
stroke: Option<Rgb>,
|
||||
/// Formatted line width.
|
||||
width: Option<String>,
|
||||
/// Formatted dash array contents.
|
||||
dash: Option<String>,
|
||||
/// Line join; PDF's initial value is 0 (miter).
|
||||
join: u8,
|
||||
}
|
||||
|
||||
enum LinkTarget {
|
||||
@@ -378,8 +434,18 @@ pub struct Page {
|
||||
content: String,
|
||||
fonts: [bool; FONT_SLOTS],
|
||||
images: Vec<ImageData>,
|
||||
/// Indexes into `images`, by content hash; a hash match is confirmed with [`ImageData::same`].
|
||||
image_index: HashMap<u64, Vec<usize>>,
|
||||
/// Pixel comparisons made while looking up duplicate pictures (test instrumentation).
|
||||
#[cfg(test)]
|
||||
image_comparisons: usize,
|
||||
/// Style operators already in effect at the end of `content`.
|
||||
style: Style,
|
||||
/// One entry per accepted [`Page::save`], restored by [`Page::restore`].
|
||||
saved: Vec<Style>,
|
||||
/// `save` calls refused at the nesting limit; the same number of `restore` calls are ignored.
|
||||
refused: u32,
|
||||
links: Vec<LinkAnnotation>,
|
||||
depth: u32,
|
||||
}
|
||||
|
||||
/// Most bytes of content per page before further drawing is dropped (keeps hostile inputs from
|
||||
@@ -387,6 +453,11 @@ pub struct Page {
|
||||
const MAX_PAGE_CONTENT: usize = 64 * 1024 * 1024;
|
||||
/// Largest image (pixels) embedded.
|
||||
const MAX_IMAGE_PIXELS: u64 = 40_000_000;
|
||||
/// Deepest `q` nesting a PDF consumer must support (the PDF specification's implementation limits).
|
||||
const MAX_SAVE_DEPTH: usize = 28;
|
||||
/// Line join styles, as the `j` operator takes them.
|
||||
const JOIN_MITER: u8 = 0;
|
||||
const JOIN_ROUND: u8 = 1;
|
||||
const MAX_PAGE_LINKS: usize = 4096;
|
||||
const MAX_LINK_URI_BYTES: usize = 4096;
|
||||
|
||||
@@ -410,6 +481,38 @@ impl Page {
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends a fill colour operator to `s` unless that colour is already in effect.
|
||||
fn set_fill(&mut self, s: &mut String, c: Rgb) {
|
||||
if self.style.fill != Some(c) {
|
||||
push_rgb(s, c, false);
|
||||
s.push('\n');
|
||||
self.style.fill = Some(c);
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends the stroke colour, width, dash and join operators to `s` that differ from the
|
||||
/// style already in effect.
|
||||
fn set_stroke(&mut self, s: &mut String, c: Rgb, width: f32, dash: &str, join: u8) {
|
||||
if self.style.stroke != Some(c) {
|
||||
push_rgb(s, c, true);
|
||||
s.push('\n');
|
||||
self.style.stroke = Some(c);
|
||||
}
|
||||
let w = n(stroke_width(width));
|
||||
if self.style.width.as_deref() != Some(w.as_str()) {
|
||||
let _ = writeln!(s, "{w} w");
|
||||
self.style.width = Some(w);
|
||||
}
|
||||
if self.style.dash.as_deref() != Some(dash) {
|
||||
let _ = writeln!(s, "[{dash}] 0 d");
|
||||
self.style.dash = Some(dash.to_string());
|
||||
}
|
||||
if self.style.join != join {
|
||||
let _ = writeln!(s, "{join} j");
|
||||
self.style.join = join;
|
||||
}
|
||||
}
|
||||
|
||||
fn link_rect(&self, rect: Rect) -> Option<[f32; 4]> {
|
||||
let Rect { x, y, width, height } = rect;
|
||||
if !finite(&[x, y, width, height]) || !extent(width) || !extent(height) || self.links.len() >= MAX_PAGE_LINKS {
|
||||
@@ -447,15 +550,23 @@ impl Page {
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the graphics state (pair with [`Page::restore`]).
|
||||
/// Saves the graphics state (pair with [`Page::restore`]). Nesting is limited to 28 levels, the
|
||||
/// depth every PDF consumer must support; a `save` beyond that is ignored, and so is the
|
||||
/// `restore` that would have matched it.
|
||||
pub fn save(&mut self) {
|
||||
self.depth = self.depth.saturating_add(1);
|
||||
if self.saved.len() >= MAX_SAVE_DEPTH {
|
||||
self.refused = self.refused.saturating_add(1);
|
||||
return;
|
||||
}
|
||||
self.saved.push(self.style.clone());
|
||||
self.content.push_str("q\n");
|
||||
}
|
||||
/// Restores the graphics state saved by [`Page::save`]; extra calls are ignored.
|
||||
pub fn restore(&mut self) {
|
||||
if self.depth > 0 {
|
||||
self.depth -= 1;
|
||||
if self.refused > 0 {
|
||||
self.refused -= 1;
|
||||
} else if let Some(style) = self.saved.pop() {
|
||||
self.style = style;
|
||||
self.content.push_str("Q\n");
|
||||
}
|
||||
}
|
||||
@@ -466,18 +577,22 @@ impl Page {
|
||||
}
|
||||
|
||||
pub fn fill_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: Rgb) {
|
||||
if !(extent(w) && extent(h) && finite(&[x, y])) {
|
||||
if !(extent(w) && extent(h) && finite(&[x, y])) || self.full() {
|
||||
return;
|
||||
}
|
||||
let s = format!("{}\n{} {} {} {} re f", rgb_op(color, false), n(x), n(self.y(y + h)), n(w), n(h));
|
||||
let mut s = String::new();
|
||||
self.set_fill(&mut s, color);
|
||||
let _ = write!(s, "{} {} {} {} re f", n(x), n(self.y(y + h)), n(w), n(h));
|
||||
self.op(&s);
|
||||
}
|
||||
|
||||
pub fn rect_stroke(&mut self, x: f32, y: f32, w: f32, h: f32, width: f32, color: Rgb) {
|
||||
if !finite(&[x, y, w, h]) {
|
||||
if !finite(&[x, y, w, h]) || self.full() {
|
||||
return;
|
||||
}
|
||||
let s = format!("{}\n{} w [] 0 d\n{} {} {} {} re S", rgb_op(color, true), n(stroke_width(width)), n(x), n(self.y(y + h)), n(w), n(h));
|
||||
let mut s = String::new();
|
||||
self.set_stroke(&mut s, color, width, "", JOIN_MITER);
|
||||
let _ = write!(s, "{} {} {} {} re S", n(x), n(self.y(y + h)), n(w), n(h));
|
||||
self.op(&s);
|
||||
}
|
||||
|
||||
@@ -487,28 +602,22 @@ impl Page {
|
||||
|
||||
/// A line with a dash pattern (`[on, off, …]` in points; empty = solid).
|
||||
pub fn stroke_line_dashed(&mut self, start: Point, end: Point, width: f32, color: Rgb, dash: &[f32]) {
|
||||
if !finite(&[start.x, start.y, end.x, end.y]) {
|
||||
if !finite(&[start.x, start.y, end.x, end.y]) || self.full() {
|
||||
return;
|
||||
}
|
||||
let s = format!(
|
||||
"{}\n{} w [{}] 0 d\n{} {} m {} {} l S",
|
||||
rgb_op(color, true),
|
||||
n(stroke_width(width)),
|
||||
dash_values(dash),
|
||||
n(start.x),
|
||||
n(self.y(start.y)),
|
||||
n(end.x),
|
||||
n(self.y(end.y))
|
||||
);
|
||||
let mut s = String::new();
|
||||
self.set_stroke(&mut s, color, width, &dash_values(dash), JOIN_MITER);
|
||||
let _ = write!(s, "{} {} m {} {} l S", n(start.x), n(self.y(start.y)), n(end.x), n(self.y(end.y)));
|
||||
self.op(&s);
|
||||
}
|
||||
|
||||
/// An open polyline.
|
||||
/// An open polyline (round joins).
|
||||
pub fn polyline(&mut self, pts: &[[f32; 2]], width: f32, color: Rgb, dash: &[f32]) {
|
||||
if pts.len() < 2 || !pts.iter().all(|p| finite(p)) {
|
||||
if pts.len() < 2 || !pts.iter().all(|p| finite(p)) || self.full() {
|
||||
return;
|
||||
}
|
||||
let mut s = format!("{}\n{} w [{}] 0 d 1 j\n", rgb_op(color, true), n(stroke_width(width)), dash_values(dash));
|
||||
let mut s = String::new();
|
||||
self.set_stroke(&mut s, color, width, &dash_values(dash), JOIN_ROUND);
|
||||
self.path(&mut s, pts, false);
|
||||
s.push_str(" S");
|
||||
self.op(&s);
|
||||
@@ -523,18 +632,17 @@ impl Page {
|
||||
}
|
||||
}
|
||||
|
||||
/// A closed polygon, filled and/or stroked.
|
||||
/// A closed polygon, filled and/or stroked (round joins).
|
||||
pub fn polygon(&mut self, pts: &[[f32; 2]], fill: Option<Rgb>, stroke: Option<(Rgb, f32)>) {
|
||||
if pts.len() < 2 || (fill.is_none() && stroke.is_none()) || !pts.iter().all(|p| finite(p)) {
|
||||
if pts.len() < 2 || (fill.is_none() && stroke.is_none()) || !pts.iter().all(|p| finite(p)) || self.full() {
|
||||
return;
|
||||
}
|
||||
let mut s = String::new();
|
||||
if let Some(f) = fill {
|
||||
s.push_str(&rgb_op(f, false));
|
||||
s.push('\n');
|
||||
self.set_fill(&mut s, f);
|
||||
}
|
||||
if let Some((c, w)) = stroke {
|
||||
let _ = writeln!(s, "{}\n{} w [] 0 d 1 j", rgb_op(c, true), n(stroke_width(w)));
|
||||
self.set_stroke(&mut s, c, w, "", JOIN_ROUND);
|
||||
}
|
||||
self.path(&mut s, pts, true);
|
||||
s.push_str(match (fill.is_some(), stroke.is_some()) {
|
||||
@@ -547,7 +655,7 @@ impl Page {
|
||||
|
||||
/// A circle approximated by four cubic Béziers.
|
||||
pub fn circle(&mut self, cx: f32, cy: f32, r: f32, fill: Option<Rgb>, stroke: Option<(Rgb, f32)>) {
|
||||
if !extent(r) || !finite(&[cx, cy]) || (fill.is_none() && stroke.is_none()) {
|
||||
if !extent(r) || !finite(&[cx, cy]) || (fill.is_none() && stroke.is_none()) || self.full() {
|
||||
return;
|
||||
}
|
||||
const K: f32 = 0.552_284_8;
|
||||
@@ -555,11 +663,10 @@ impl Page {
|
||||
let y = self.y(cy);
|
||||
let mut s = String::new();
|
||||
if let Some(f) = fill {
|
||||
s.push_str(&rgb_op(f, false));
|
||||
s.push('\n');
|
||||
self.set_fill(&mut s, f);
|
||||
}
|
||||
if let Some((c, w)) = stroke {
|
||||
let _ = writeln!(s, "{}\n{} w [] 0 d", rgb_op(c, true), n(stroke_width(w)));
|
||||
self.set_stroke(&mut s, c, w, "", JOIN_MITER);
|
||||
}
|
||||
let _ = write!(
|
||||
s,
|
||||
@@ -614,19 +721,24 @@ impl Page {
|
||||
let size = n(size.min(10_000.0));
|
||||
let (sn, cs) = if angle.is_finite() { angle.sin_cos() } else { (0.0, 1.0) };
|
||||
// Clockwise in y-down = counter-clockwise negated in PDF's y-up space.
|
||||
let mut s = format!("BT\n{}\n{} {} {} {} {} {} Tm\n", rgb_op(color, false), n(cs), n(-sn), n(sn), n(cs), n(origin.x), n(self.y(origin.y)));
|
||||
let tm = format!("{} {} {} {} {} {} Tm\n", n(cs), n(-sn), n(sn), n(cs), n(origin.x), n(self.y(origin.y)));
|
||||
// Symbol text is one run in Symbol's own encoding (the CID font has no maths symbols).
|
||||
if font == Font::Symbol {
|
||||
if let Some(f) = self.fonts.get_mut(fi) {
|
||||
*f = true;
|
||||
}
|
||||
let _ = writeln!(s, "/F{} {} Tf\n{} Tj", fi + 1, size, pdf_string_in(text, true));
|
||||
s.push_str("ET");
|
||||
if self.full() {
|
||||
return;
|
||||
}
|
||||
let mut s = String::new();
|
||||
self.set_fill(&mut s, color);
|
||||
let _ = write!(s, "BT\n{tm}/F{} {} Tf\n{} Tj\nET", fi + 1, size, pdf_string_in(text, true));
|
||||
self.op(&s);
|
||||
return;
|
||||
}
|
||||
// Runs of WinAnsi text use `font`; runs it cannot encode use the CID font. Each `Tj`
|
||||
// advances the text position, so the runs follow each other on the baseline.
|
||||
let mut runs = String::new();
|
||||
let mut rest = text;
|
||||
while let Some(first) = rest.chars().next() {
|
||||
let cid = needs_cid(first);
|
||||
@@ -636,10 +748,21 @@ impl Page {
|
||||
if let Some(f) = self.fonts.get_mut(slot) {
|
||||
*f = true;
|
||||
}
|
||||
let _ = writeln!(s, "/F{} {} Tf\n{} Tj", slot + 1, size, string);
|
||||
let first_run = runs.is_empty();
|
||||
let _ = writeln!(runs, "/F{} {} Tf", slot + 1, size);
|
||||
if first_run {
|
||||
runs.push_str(&tm);
|
||||
}
|
||||
let _ = writeln!(runs, "{string} Tj");
|
||||
rest = tail;
|
||||
}
|
||||
s.push_str("ET");
|
||||
if self.full() {
|
||||
return;
|
||||
}
|
||||
// The colour goes in front of the text object, so it is tracked like any other style.
|
||||
let mut colour = String::new();
|
||||
self.set_fill(&mut colour, color);
|
||||
let s = format!("{colour}BT\n{runs}ET");
|
||||
self.op(&s);
|
||||
}
|
||||
|
||||
@@ -652,16 +775,47 @@ impl Page {
|
||||
if pw == 0 || ph == 0 || px > MAX_IMAGE_PIXELS || (rgba.len() as u64) < px * 4 || !(extent(w) && extent(h) && finite(&[x, y])) {
|
||||
return;
|
||||
}
|
||||
// The image is drawn inside its own q/Q, which counts towards the nesting limit.
|
||||
if self.saved.len() >= MAX_SAVE_DEPTH {
|
||||
return;
|
||||
}
|
||||
let px = px as usize;
|
||||
let mut rgb = Vec::with_capacity(px * 3);
|
||||
let mut alpha = Vec::with_capacity(px);
|
||||
for p in rgba.as_chunks::<4>().0.iter().take(px) {
|
||||
// The alpha plane is only allocated once a non-opaque pixel shows up.
|
||||
let mut alpha: Option<Vec<u8>> = None;
|
||||
for (i, p) in rgba.as_chunks::<4>().0.iter().take(px).enumerate() {
|
||||
rgb.extend_from_slice(&[p[0], p[1], p[2]]);
|
||||
alpha.push(p[3]);
|
||||
match &mut alpha {
|
||||
Some(a) => a.push(p[3]),
|
||||
None if p[3] != 255 => {
|
||||
let mut a = Vec::with_capacity(px);
|
||||
a.resize(i, 255);
|
||||
a.push(p[3]);
|
||||
alpha = Some(a);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
let alpha = if alpha.iter().all(|a| *a == 255) { None } else { Some(alpha) };
|
||||
let idx = self.images.len();
|
||||
self.images.push(ImageData { width: pw, height: ph, rgb, alpha });
|
||||
let img = ImageData::new(pw, ph, rgb, alpha);
|
||||
// The same picture drawn again on this page shares one XObject.
|
||||
let candidates = self.image_index.entry(img.hash).or_default();
|
||||
#[cfg(test)]
|
||||
let comparisons = &mut self.image_comparisons;
|
||||
let known = candidates.iter().copied().find(|&i| {
|
||||
#[cfg(test)]
|
||||
{
|
||||
*comparisons += 1;
|
||||
}
|
||||
self.images.get(i).is_some_and(|o| o.same(&img))
|
||||
});
|
||||
let idx = match known {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
candidates.push(self.images.len());
|
||||
self.images.push(img);
|
||||
self.images.len() - 1
|
||||
}
|
||||
};
|
||||
let s = format!("q {} 0 0 {} {} {} cm /Im{} Do Q", n(w), n(h), n(x), n(self.y(y + h)), idx + 1);
|
||||
self.op(&s);
|
||||
}
|
||||
@@ -738,6 +892,49 @@ fn pdf_text_string(text: &str) -> String {
|
||||
s
|
||||
}
|
||||
|
||||
/// Starts object `id` at the current end of `out` and records its offset for the xref table.
|
||||
fn begin_obj(out: &mut Vec<u8>, offsets: &mut Vec<usize>, id: u32) {
|
||||
let i = id as usize;
|
||||
if offsets.len() <= i {
|
||||
offsets.resize(i + 1, 0);
|
||||
}
|
||||
if let Some(slot) = offsets.get_mut(i) {
|
||||
*slot = out.len();
|
||||
}
|
||||
let _ = writeln!(Utf8(out), "{id} 0 obj");
|
||||
}
|
||||
|
||||
/// Writes `id 0 obj <body> endobj`.
|
||||
fn dict_obj(out: &mut Vec<u8>, offsets: &mut Vec<usize>, id: u32, body: &str) {
|
||||
begin_obj(out, offsets, id);
|
||||
out.extend_from_slice(body.as_bytes());
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
|
||||
/// Writes a stream object: `dict` (the dictionary entries without `<<`, `>>` and `/Length`, may be
|
||||
/// empty) and `data` as the stream body.
|
||||
fn stream_obj(out: &mut Vec<u8>, offsets: &mut Vec<usize>, id: u32, dict: &str, data: &[u8]) {
|
||||
begin_obj(out, offsets, id);
|
||||
out.extend_from_slice(b"<<");
|
||||
if !dict.is_empty() {
|
||||
out.push(b' ');
|
||||
out.extend_from_slice(dict.as_bytes());
|
||||
}
|
||||
let _ = write!(Utf8(out), " /Length {} >>\nstream\n", data.len());
|
||||
out.extend_from_slice(data);
|
||||
out.extend_from_slice(b"\nendstream\nendobj\n");
|
||||
}
|
||||
|
||||
/// Lets `write!` append text to a byte buffer.
|
||||
struct Utf8<'a>(&'a mut Vec<u8>);
|
||||
|
||||
impl std::fmt::Write for Utf8<'_> {
|
||||
fn write_str(&mut self, s: &str) -> std::fmt::Result {
|
||||
self.0.extend_from_slice(s.as_bytes());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl PdfDoc {
|
||||
pub fn new() -> PdfDoc {
|
||||
PdfDoc::default()
|
||||
@@ -747,7 +944,20 @@ impl PdfDoc {
|
||||
pub fn add_page(&mut self, width_pt: f32, height_pt: f32) -> &mut Page {
|
||||
let width = if width_pt.is_finite() { width_pt.clamp(3.0, 14_400.0) } else { 612.0 };
|
||||
let height = if height_pt.is_finite() { height_pt.clamp(3.0, 14_400.0) } else { 792.0 };
|
||||
self.pages.push(Page { width, height, content: String::new(), fonts: [false; FONT_SLOTS], images: Vec::new(), links: Vec::new(), depth: 0 });
|
||||
self.pages.push(Page {
|
||||
width,
|
||||
height,
|
||||
content: String::new(),
|
||||
fonts: [false; FONT_SLOTS],
|
||||
images: Vec::new(),
|
||||
image_index: HashMap::new(),
|
||||
#[cfg(test)]
|
||||
image_comparisons: 0,
|
||||
links: Vec::new(),
|
||||
style: Style::default(),
|
||||
saved: Vec::new(),
|
||||
refused: 0,
|
||||
});
|
||||
let i = self.pages.len() - 1;
|
||||
&mut self.pages[i]
|
||||
}
|
||||
@@ -762,10 +972,22 @@ impl PdfDoc {
|
||||
}
|
||||
|
||||
/// Serializes the document. A document without pages gets one blank Letter page.
|
||||
///
|
||||
/// Every object is written straight into the output buffer (nothing is staged in an
|
||||
/// intermediate list of bodies); only the xref table needs the offset of each object id, and
|
||||
/// the ids are contiguous from 1. Objects are therefore in the file in the order they are
|
||||
/// numbered during writing (fonts, then per page its new images, content stream and page
|
||||
/// dictionary), with the catalog, page tree and info dictionary last.
|
||||
pub fn finish(mut self) -> Vec<u8> {
|
||||
if self.pages.is_empty() {
|
||||
self.add_page(612.0, 792.0);
|
||||
}
|
||||
for page in &mut self.pages {
|
||||
page.refused = 0;
|
||||
while !page.saved.is_empty() {
|
||||
page.restore();
|
||||
}
|
||||
}
|
||||
// Object numbering: 1 catalog, 2 page tree, 3 info, then fonts, then per page.
|
||||
let mut used = [false; FONT_SLOTS];
|
||||
for p in &self.pages {
|
||||
@@ -782,7 +1004,13 @@ impl PdfDoc {
|
||||
next += if i == CID_FONT { 3 } else { 1 };
|
||||
}
|
||||
}
|
||||
let mut objects: Vec<(u32, Vec<u8>)> = Vec::new();
|
||||
// Page contents are moved out so each one is freed as soon as it is in the output.
|
||||
let contents: Vec<String> = self.pages.iter_mut().map(|p| std::mem::take(&mut p.content)).collect();
|
||||
let page_links: Vec<Vec<LinkAnnotation>> = self.pages.iter_mut().map(|p| std::mem::take(&mut p.links)).collect();
|
||||
let reserve = contents.iter().fold(4096usize, |acc, c| acc.saturating_add(c.len()).saturating_add(1024));
|
||||
let mut out: Vec<u8> = Vec::with_capacity(reserve);
|
||||
out.extend_from_slice(b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n");
|
||||
let mut offsets: Vec<usize> = vec![0; 4];
|
||||
let mut kids = Vec::new();
|
||||
let mut annotations = Vec::new();
|
||||
let page_count = self.pages.len();
|
||||
@@ -792,86 +1020,87 @@ impl PdfDoc {
|
||||
let _ = write!(font_dict, "/F{} {} 0 R ", i + 1, id);
|
||||
if i == CID_FONT {
|
||||
let (cid_id, desc_id) = (id + 1, id + 2);
|
||||
objects.push((
|
||||
dict_obj(
|
||||
&mut out,
|
||||
&mut offsets,
|
||||
*id,
|
||||
format!(
|
||||
&format!(
|
||||
"<< /Type /Font /Subtype /Type0 /BaseFont /{CID_BASE_FONT} /Encoding /UniGB-UTF16-H /DescendantFonts [{cid_id} 0 R] >>"
|
||||
)
|
||||
.into_bytes(),
|
||||
));
|
||||
objects.push((
|
||||
),
|
||||
);
|
||||
dict_obj(
|
||||
&mut out,
|
||||
&mut offsets,
|
||||
cid_id,
|
||||
format!(
|
||||
&format!(
|
||||
"<< /Type /Font /Subtype /CIDFontType0 /BaseFont /{CID_BASE_FONT} /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 4 >> /FontDescriptor {desc_id} 0 R /DW {CID_WIDTH} >>"
|
||||
)
|
||||
.into_bytes(),
|
||||
));
|
||||
objects.push((
|
||||
),
|
||||
);
|
||||
dict_obj(
|
||||
&mut out,
|
||||
&mut offsets,
|
||||
desc_id,
|
||||
format!(
|
||||
&format!(
|
||||
"<< /Type /FontDescriptor /FontName /{CID_BASE_FONT} /Flags 6 /FontBBox [-25 -254 1000 880] /ItalicAngle 0 /Ascent 880 /Descent -120 /CapHeight 880 /StemV 93 >>"
|
||||
)
|
||||
.into_bytes(),
|
||||
));
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let name = Font::ALL.get(i).copied().unwrap_or_default().base_name();
|
||||
let enc = if name.starts_with("Symbol") { "" } else { " /Encoding /WinAnsiEncoding" };
|
||||
objects.push((*id, format!("<< /Type /Font /Subtype /Type1 /BaseFont /{name}{enc} >>").into_bytes()));
|
||||
dict_obj(&mut out, &mut offsets, *id, &format!("<< /Type /Font /Subtype /Type1 /BaseFont /{name}{enc} >>"));
|
||||
}
|
||||
}
|
||||
for page in &mut self.pages {
|
||||
while page.depth > 0 {
|
||||
page.restore();
|
||||
}
|
||||
// Images already written, by content hash: (page, image, object id). A hash match is
|
||||
// confirmed by comparing the pixels before an object is shared.
|
||||
let mut written: HashMap<u64, Vec<(usize, usize, u32)>> = HashMap::new();
|
||||
for (pi, ((page, content), links)) in self.pages.iter().zip(contents).zip(page_links).enumerate() {
|
||||
let mut xobjects = String::new();
|
||||
for (k, img) in page.images.iter().enumerate() {
|
||||
let img_id = next;
|
||||
next += 1;
|
||||
let smask = match &img.alpha {
|
||||
Some(a) => {
|
||||
let id = next;
|
||||
let known = written.get(&img.hash).and_then(|c| {
|
||||
c.iter().find_map(|(p, i, id)| {
|
||||
let other = self.pages.get(*p)?.images.get(*i)?;
|
||||
img.same(other).then_some(*id)
|
||||
})
|
||||
});
|
||||
let img_id = match known {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let img_id = next;
|
||||
next += 1;
|
||||
let data = run_length(a);
|
||||
let mut o = format!(
|
||||
"<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /RunLengthDecode /Length {} >>\nstream\n",
|
||||
img.width,
|
||||
img.height,
|
||||
data.len()
|
||||
)
|
||||
.into_bytes();
|
||||
o.extend_from_slice(&data);
|
||||
o.extend_from_slice(b"\nendstream");
|
||||
objects.push((id, o));
|
||||
format!(" /SMask {id} 0 R")
|
||||
let mut dict = format!("/Type /XObject /Subtype /Image /Width {} /Height {} /BitsPerComponent 8", img.width, img.height);
|
||||
let smask_id = img.alpha.as_ref().map(|_| {
|
||||
let id = next;
|
||||
next += 1;
|
||||
id
|
||||
});
|
||||
dict.push_str(" /ColorSpace /DeviceRGB");
|
||||
if let Some(id) = smask_id {
|
||||
let _ = write!(dict, " /SMask {id} 0 R");
|
||||
}
|
||||
dict.push_str(" /Filter /RunLengthDecode");
|
||||
stream_obj(&mut out, &mut offsets, img_id, &dict, &run_length(&img.rgb));
|
||||
if let (Some(id), Some(a)) = (smask_id, &img.alpha) {
|
||||
let dict = format!(
|
||||
"/Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /RunLengthDecode",
|
||||
img.width, img.height
|
||||
);
|
||||
stream_obj(&mut out, &mut offsets, id, &dict, &run_length(a));
|
||||
}
|
||||
written.entry(img.hash).or_default().push((pi, k, img_id));
|
||||
img_id
|
||||
}
|
||||
None => String::new(),
|
||||
};
|
||||
let data = run_length(&img.rgb);
|
||||
let mut o = format!(
|
||||
"<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace /DeviceRGB /BitsPerComponent 8{} /Filter /RunLengthDecode /Length {} >>\nstream\n",
|
||||
img.width,
|
||||
img.height,
|
||||
smask,
|
||||
data.len()
|
||||
)
|
||||
.into_bytes();
|
||||
o.extend_from_slice(&data);
|
||||
o.extend_from_slice(b"\nendstream");
|
||||
objects.push((img_id, o));
|
||||
let _ = write!(xobjects, "/Im{} {} 0 R ", k + 1, img_id);
|
||||
}
|
||||
let content_id = next;
|
||||
let page_id = next + 1;
|
||||
next += 2;
|
||||
let body = std::mem::take(&mut page.content);
|
||||
let mut o = format!("<< /Length {} >>\nstream\n", body.len()).into_bytes();
|
||||
o.extend_from_slice(body.as_bytes());
|
||||
o.extend_from_slice(b"endstream");
|
||||
objects.push((content_id, o));
|
||||
stream_obj(&mut out, &mut offsets, content_id, "", content.as_bytes());
|
||||
drop(content);
|
||||
let xo = if xobjects.is_empty() { String::new() } else { format!(" /XObject << {xobjects}>>") };
|
||||
let mut link_refs = String::new();
|
||||
for link in std::mem::take(&mut page.links) {
|
||||
for link in links {
|
||||
if matches!(&link.target, LinkTarget::Page { index, .. } if *index >= page_count) {
|
||||
continue;
|
||||
}
|
||||
@@ -881,19 +1110,20 @@ impl PdfDoc {
|
||||
annotations.push((id, link));
|
||||
}
|
||||
let annots = if link_refs.is_empty() { String::new() } else { format!(" /Annots [{link_refs}]") };
|
||||
objects.push((
|
||||
dict_obj(
|
||||
&mut out,
|
||||
&mut offsets,
|
||||
page_id,
|
||||
format!(
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {} {}] /Resources << /ProcSet [/PDF /Text /ImageB /ImageC] /Font << {}>>{} >> /Contents {} 0 R{} >>",
|
||||
&format!(
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {} {}] /Resources << /Font << {}>>{} >> /Contents {} 0 R{} >>",
|
||||
n(page.width),
|
||||
n(page.height),
|
||||
font_dict,
|
||||
xo,
|
||||
content_id,
|
||||
annots
|
||||
)
|
||||
.into_bytes(),
|
||||
));
|
||||
),
|
||||
);
|
||||
kids.push(page_id);
|
||||
}
|
||||
for (id, link) in annotations {
|
||||
@@ -906,14 +1136,16 @@ impl PdfDoc {
|
||||
format!("/Dest [{page_id} 0 R /XYZ {} {} null]", n(x), n(height - y))
|
||||
}
|
||||
};
|
||||
objects.push((
|
||||
dict_obj(
|
||||
&mut out,
|
||||
&mut offsets,
|
||||
id,
|
||||
format!("<< /Type /Annot /Subtype /Link /Rect [{} {} {} {}] /Border [0 0 0] {action} >>", n(x0), n(y0), n(x1), n(y1)).into_bytes(),
|
||||
));
|
||||
&format!("<< /Type /Annot /Subtype /Link /Rect [{} {} {} {}] /Border [0 0 0] {action} >>", n(x0), n(y0), n(x1), n(y1)),
|
||||
);
|
||||
}
|
||||
objects.push((1, b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()));
|
||||
dict_obj(&mut out, &mut offsets, 1, "<< /Type /Catalog /Pages 2 0 R >>");
|
||||
let kids_s: Vec<String> = kids.iter().map(|k| format!("{k} 0 R")).collect();
|
||||
objects.push((2, format!("<< /Type /Pages /Kids [{}] /Count {} >>", kids_s.join(" "), kids.len()).into_bytes()));
|
||||
dict_obj(&mut out, &mut offsets, 2, &format!("<< /Type /Pages /Kids [{}] /Count {} >>", kids_s.join(" "), kids.len()));
|
||||
let creator = if self.info.creator.is_empty() { "GridCraft" } else { &self.info.creator };
|
||||
let mut info = format!("<< /Producer {} /Creator {}", pdf_text_string("GridCraft PDF"), pdf_text_string(creator));
|
||||
if !self.info.title.is_empty() {
|
||||
@@ -923,27 +1155,14 @@ impl PdfDoc {
|
||||
let _ = write!(info, " /Author {}", pdf_text_string(&self.info.author));
|
||||
}
|
||||
info.push_str(" >>");
|
||||
objects.push((3, info.into_bytes()));
|
||||
objects.sort_by_key(|(id, _)| *id);
|
||||
dict_obj(&mut out, &mut offsets, 3, &info);
|
||||
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
out.extend_from_slice(b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n");
|
||||
let mut offsets = vec![0usize; next as usize];
|
||||
for (id, body) in &objects {
|
||||
if let Some(slot) = offsets.get_mut(*id as usize) {
|
||||
*slot = out.len();
|
||||
}
|
||||
out.extend_from_slice(format!("{id} 0 obj\n").as_bytes());
|
||||
out.extend_from_slice(body);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = out.len();
|
||||
let mut x = format!("xref\n0 {}\n0000000000 65535 f \n", next);
|
||||
let _ = write!(Utf8(&mut out), "xref\n0 {}\n0000000000 65535 f \n", next);
|
||||
for off in offsets.iter().skip(1) {
|
||||
let _ = writeln!(x, "{off:010} 00000 n ");
|
||||
let _ = writeln!(Utf8(&mut out), "{off:010} 00000 n ");
|
||||
}
|
||||
let _ = write!(x, "trailer\n<< /Size {} /Root 1 0 R /Info 3 0 R >>\nstartxref\n{}\n%%EOF\n", next, xref);
|
||||
out.extend_from_slice(x.as_bytes());
|
||||
let _ = write!(Utf8(&mut out), "trailer\n<< /Size {} /Root 1 0 R /Info 3 0 R >>\nstartxref\n{}\n%%EOF\n", next, xref);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
+574
-2
@@ -2,6 +2,9 @@ use super::*;
|
||||
use std::error::Error;
|
||||
use std::io;
|
||||
|
||||
mod reference;
|
||||
use reference::{OldPage, interpret};
|
||||
|
||||
fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
hay.windows(needle.len()).position(|w| w == needle)
|
||||
}
|
||||
@@ -189,6 +192,558 @@ fn hostile_inputs_never_panic() -> Result<(), Box<dyn Error>> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Writer cleanup: equivalence with the previous content writer, structure, and one test per
|
||||
// behaviour change.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// Offsets of the in-use objects, by id, from the xref table.
|
||||
fn xref_offsets(pdf: &[u8]) -> Vec<usize> {
|
||||
let sx = rfind(pdf, b"startxref\n").unwrap();
|
||||
let rest = std::str::from_utf8(&pdf[sx + 10..]).unwrap();
|
||||
let off: usize = rest.lines().next().unwrap().trim().parse().unwrap();
|
||||
let table = std::str::from_utf8(&pdf[off..sx]).unwrap();
|
||||
table.lines().skip(2).take_while(|l| l.len() == 18 || l.len() == 19).map(|e| e[..10].parse().unwrap()).collect()
|
||||
}
|
||||
|
||||
struct Obj {
|
||||
dict: String,
|
||||
stream: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Parses every object of a document written by this crate, checking that each stream's
|
||||
/// `/Length` matches its data and is followed by `endstream`/`endobj`.
|
||||
fn parse_objects(pdf: &[u8]) -> Vec<Obj> {
|
||||
let offsets = xref_offsets(pdf);
|
||||
let mut objs = Vec::new();
|
||||
for (id, off) in offsets.iter().enumerate() {
|
||||
if id == 0 {
|
||||
objs.push(Obj { dict: String::new(), stream: None });
|
||||
continue;
|
||||
}
|
||||
let tail = &pdf[*off..];
|
||||
let head = format!("{id} 0 obj\n");
|
||||
assert!(tail.starts_with(head.as_bytes()), "object {id}");
|
||||
let body = &tail[head.len()..];
|
||||
let end = find(body, b"\nendobj\n").unwrap();
|
||||
match find(body, b"\nstream\n").filter(|s| *s < end) {
|
||||
Some(s) => {
|
||||
let dict = String::from_utf8(body[..s].to_vec()).unwrap();
|
||||
let l = dict.find("/Length ").unwrap() + 8;
|
||||
let len: usize = dict[l..].split(' ').next().unwrap().parse().unwrap();
|
||||
let data = &body[s + 8..];
|
||||
assert!(data[len..].starts_with(b"\nendstream\nendobj\n"), "object {id}: /Length {len} is wrong");
|
||||
objs.push(Obj { dict, stream: Some(data[..len].to_vec()) });
|
||||
}
|
||||
None => objs.push(Obj { dict: String::from_utf8(body[..end].to_vec()).unwrap(), stream: None }),
|
||||
}
|
||||
}
|
||||
objs
|
||||
}
|
||||
|
||||
fn ref_of(dict: &str, key: &str) -> Option<usize> {
|
||||
let i = dict.find(key)? + key.len();
|
||||
dict[i..].trim_start().split(' ').next()?.parse().ok()
|
||||
}
|
||||
|
||||
struct ParsedPage {
|
||||
media: String,
|
||||
content: String,
|
||||
/// `(Im name, object id)`.
|
||||
xobjects: Vec<(String, usize)>,
|
||||
}
|
||||
|
||||
fn parse_pages(pdf: &[u8]) -> (Vec<Obj>, Vec<ParsedPage>) {
|
||||
let objs = parse_objects(pdf);
|
||||
let mut pages = Vec::new();
|
||||
for o in &objs {
|
||||
if !o.dict.contains("/Type /Page ") {
|
||||
continue;
|
||||
}
|
||||
let m = o.dict.find("/MediaBox [").unwrap() + 10;
|
||||
let media = o.dict[m..m + o.dict[m..].find(']').unwrap() + 1].to_string();
|
||||
let content_id = ref_of(&o.dict, "/Contents").unwrap();
|
||||
let content = String::from_utf8(objs[content_id].stream.clone().unwrap()).unwrap();
|
||||
let mut xobjects = Vec::new();
|
||||
if let Some(x) = o.dict.find("/XObject << ") {
|
||||
let rest = &o.dict[x + 11..];
|
||||
let rest = &rest[..rest.find(">>").unwrap()];
|
||||
let toks: Vec<&str> = rest.split_whitespace().collect();
|
||||
for c in toks.chunks(4) {
|
||||
xobjects.push((c[0].to_string(), c[1].parse().unwrap()));
|
||||
}
|
||||
}
|
||||
pages.push(ParsedPage { media, content, xobjects });
|
||||
}
|
||||
(objs, pages)
|
||||
}
|
||||
|
||||
fn rle_decode(d: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < d.len() {
|
||||
let l = d[i];
|
||||
i += 1;
|
||||
if l == 128 {
|
||||
break;
|
||||
} else if l < 128 {
|
||||
out.extend_from_slice(&d[i..i + l as usize + 1]);
|
||||
i += l as usize + 1;
|
||||
} else {
|
||||
out.extend(std::iter::repeat_n(d[i], 257 - l as usize));
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Decodes an image XObject: `(width, height, rgb, alpha)`.
|
||||
fn decode_image(objs: &[Obj], id: usize) -> (u32, u32, Vec<u8>, Option<Vec<u8>>) {
|
||||
let o = &objs[id];
|
||||
let w = ref_of(&o.dict, "/Width").unwrap() as u32;
|
||||
let h = ref_of(&o.dict, "/Height").unwrap() as u32;
|
||||
let rgb = rle_decode(o.stream.as_ref().unwrap());
|
||||
let alpha = ref_of(&o.dict, "/SMask").map(|m| rle_decode(objs[m].stream.as_ref().unwrap()));
|
||||
(w, h, rgb, alpha)
|
||||
}
|
||||
|
||||
/// Deterministic pseudo-random numbers for the corpus.
|
||||
struct Lcg(u64);
|
||||
impl Lcg {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
self.0 >> 33
|
||||
}
|
||||
fn below(&mut self, n: u64) -> u64 {
|
||||
self.next() % n
|
||||
}
|
||||
fn f(&mut self) -> f32 {
|
||||
self.below(600) as f32 + self.below(4) as f32 * 0.25
|
||||
}
|
||||
}
|
||||
|
||||
const PALETTE: [Rgb; 4] = [[0, 0, 0], [255, 0, 0], [12, 200, 99], [255, 255, 255]];
|
||||
const WIDTHS: [f32; 3] = [0.5, 1.0, 2.5];
|
||||
const DASHES: [&[f32]; 3] = [&[], &[3.0, 2.0], &[1.0]];
|
||||
|
||||
/// A picture used by the corpus: `(rgba, w, h)`.
|
||||
fn pictures() -> Vec<(Vec<u8>, u32, u32)> {
|
||||
let mut with_alpha = Vec::new();
|
||||
for i in 0..16u8 {
|
||||
with_alpha.extend_from_slice(&[i * 10, 0, 7, if i < 8 { 255 } else { 128 }]);
|
||||
}
|
||||
let opaque: Vec<u8> = (0..4 * 9).map(|i| if i % 4 == 3 { 255 } else { (i * 7) as u8 }).collect();
|
||||
let mut last_px = vec![200u8; 4 * 6];
|
||||
for px in last_px.chunks_mut(4) {
|
||||
px[3] = 255;
|
||||
}
|
||||
last_px[4 * 5 + 3] = 3; // only the very last pixel is translucent
|
||||
vec![(with_alpha, 4, 4), (opaque, 3, 3), (last_px, 3, 2)]
|
||||
}
|
||||
|
||||
/// Applies a random operation to both writers.
|
||||
fn random_op(rng: &mut Lcg, new: &mut Page, old: &mut OldPage, pics: &[(Vec<u8>, u32, u32)], depth: &mut usize) {
|
||||
let color = PALETTE[rng.below(4) as usize];
|
||||
let width = WIDTHS[rng.below(3) as usize];
|
||||
let dash = DASHES[rng.below(3) as usize];
|
||||
let (x, y, w, h) = (rng.f(), rng.f(), rng.f() / 4.0 + 1.0, rng.f() / 4.0 + 1.0);
|
||||
let pts = [[x, y], [x + w, y], [x + w, y + h]];
|
||||
let stroke = if rng.below(2) == 0 { Some((PALETTE[rng.below(4) as usize], WIDTHS[rng.below(3) as usize])) } else { None };
|
||||
let fill = if rng.below(2) == 0 { Some(color) } else { None };
|
||||
match rng.below(14) {
|
||||
0 | 1 => {
|
||||
new.fill_rect(x, y, w, h, color);
|
||||
old.fill_rect(x, y, w, h, color);
|
||||
}
|
||||
2 => {
|
||||
new.rect_stroke(x, y, w, h, width, color);
|
||||
old.rect_stroke(x, y, w, h, width, color);
|
||||
}
|
||||
3 => {
|
||||
new.stroke_line_dashed(Point::new(x, y), Point::new(x + w, y + h), width, color, dash);
|
||||
old.stroke_line_dashed(Point::new(x, y), Point::new(x + w, y + h), width, color, dash);
|
||||
}
|
||||
4 => {
|
||||
new.polyline(&pts, width, color, dash);
|
||||
old.polyline(&pts, width, color, dash);
|
||||
}
|
||||
5 => {
|
||||
new.polygon(&pts, fill, stroke);
|
||||
old.polygon(&pts, fill, stroke);
|
||||
}
|
||||
6 => {
|
||||
new.circle(x, y, w, fill, stroke);
|
||||
old.circle(x, y, w, fill, stroke);
|
||||
}
|
||||
7 | 8 => {
|
||||
let font = Font::ALL[rng.below(12) as usize];
|
||||
let angle = if rng.below(3) == 0 { 0.5 } else { 0.0 };
|
||||
new.text_rotated(Point::new(x, y), 10.0, font, color, "Hi (there)", angle);
|
||||
old.text_rotated(Point::new(x, y), 10.0, font, color, "Hi (there)", angle);
|
||||
}
|
||||
9 => {
|
||||
new.clip_rect(x, y, w, h);
|
||||
old.clip_rect(x, y, w, h);
|
||||
}
|
||||
10 if *depth < 20 => {
|
||||
new.save();
|
||||
old.save();
|
||||
*depth += 1;
|
||||
}
|
||||
11 if *depth > 0 => {
|
||||
new.restore();
|
||||
old.restore();
|
||||
*depth -= 1;
|
||||
}
|
||||
12 => {
|
||||
let (px, pw, ph) = &pics[rng.below(3) as usize];
|
||||
new.image_rgba(Rect::new(x, y, w, h), px, [*pw, *ph]);
|
||||
old.image(x, y, w, h);
|
||||
}
|
||||
_ => {
|
||||
new.text(x, y, 9.0, Font::Helvetica, color, "Plain");
|
||||
old.text_rotated(Point::new(x, y), 9.0, Font::Helvetica, color, "Plain", 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the same random multi-page document with the new writer and the old content writer.
|
||||
fn corpus_doc(seed: u64) -> (Vec<u8>, Vec<String>) {
|
||||
let mut rng = Lcg(seed);
|
||||
let pics = pictures();
|
||||
let mut doc = PdfDoc::new();
|
||||
let mut old_pages = Vec::new();
|
||||
for _ in 0..(1 + rng.below(4)) {
|
||||
let mut old = OldPage::new(792.0);
|
||||
let mut depth = 0usize;
|
||||
let page = doc.add_page(612.0, 792.0);
|
||||
for _ in 0..120 {
|
||||
random_op(&mut rng, page, &mut old, &pics, &mut depth);
|
||||
}
|
||||
while depth > 0 {
|
||||
page.restore();
|
||||
old.restore();
|
||||
depth -= 1;
|
||||
}
|
||||
old_pages.push(old.content);
|
||||
}
|
||||
(doc.finish(), old_pages)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_writer_is_equivalent_to_the_old_one() -> Result<(), Box<dyn Error>> {
|
||||
let mut style_new = 0;
|
||||
let mut style_old = 0;
|
||||
for seed in 1..=40u64 {
|
||||
let (pdf, old_pages) = corpus_doc(seed);
|
||||
check_xref(&pdf)?;
|
||||
let (_, pages) = parse_pages(&pdf);
|
||||
assert_eq!(pages.len(), old_pages.len());
|
||||
for (p, old) in pages.iter().zip(&old_pages) {
|
||||
assert_eq!(p.media, "[0 0 612 792]");
|
||||
let new = interpret(&p.content);
|
||||
let old = interpret(old);
|
||||
assert!(new.balanced && old.balanced, "q/Q balanced (seed {seed})");
|
||||
assert_eq!(new.paints.len(), old.paints.len(), "seed {seed}");
|
||||
for (i, (a, b)) in new.paints.iter().zip(&old.paints).enumerate() {
|
||||
// Round joins leaking from polylines into later rectangles is the old bug (B7),
|
||||
// compared separately in `join_does_not_leak_from_polylines`.
|
||||
let mut a = a.clone();
|
||||
let mut b = b.clone();
|
||||
a.join.clear();
|
||||
b.join.clear();
|
||||
assert_eq!(a, b, "seed {seed}, painting op {i}");
|
||||
}
|
||||
style_new += new.style_ops;
|
||||
style_old += old.style_ops;
|
||||
}
|
||||
}
|
||||
// Sanity: the corpus really contains style changes, and the new writer emits fewer.
|
||||
assert!(style_old > 1000, "corpus too small: {style_old}");
|
||||
assert!(style_new < style_old, "{style_new} vs {style_old}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structure_is_valid_for_all_documents() -> Result<(), Box<dyn Error>> {
|
||||
for seed in 1..=40u64 {
|
||||
let (pdf, _) = corpus_doc(seed);
|
||||
check_xref(&pdf)?;
|
||||
parse_objects(&pdf); // every /Length matches its data
|
||||
}
|
||||
let empty = PdfDoc::new().finish();
|
||||
check_xref(&empty)?;
|
||||
parse_objects(&empty);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_decode_to_their_pixels() {
|
||||
let pics = pictures();
|
||||
let mut d = PdfDoc::new();
|
||||
for _ in 0..2 {
|
||||
let p = d.add_page(300.0, 300.0);
|
||||
for (px, w, h) in &pics {
|
||||
p.image_rgba(Rect::new(1.0, 1.0, 20.0, 20.0), px, [*w, *h]);
|
||||
}
|
||||
}
|
||||
let pdf = d.finish();
|
||||
let (objs, pages) = parse_pages(&pdf);
|
||||
assert_eq!(pages.len(), 2);
|
||||
for page in &pages {
|
||||
assert_eq!(page.xobjects.len(), 3);
|
||||
for ((px, w, h), (_, id)) in pics.iter().zip(&page.xobjects) {
|
||||
let (dw, dh, rgb, alpha) = decode_image(&objs, *id);
|
||||
assert_eq!((dw, dh), (*w, *h));
|
||||
let want_rgb: Vec<u8> = px.chunks(4).flat_map(|c| [c[0], c[1], c[2]]).collect();
|
||||
assert_eq!(rgb, want_rgb);
|
||||
let want_alpha: Vec<u8> = px.chunks(4).map(|c| c[3]).collect();
|
||||
match alpha {
|
||||
Some(a) => assert_eq!(a, want_alpha),
|
||||
None => assert!(want_alpha.iter().all(|a| *a == 255)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// O6: no alpha plane (and no SMask object) for an opaque image; a single translucent pixel
|
||||
/// anywhere (first, middle, last) produces the full plane.
|
||||
#[test]
|
||||
fn opaque_images_have_no_soft_mask() {
|
||||
let mut d = PdfDoc::new();
|
||||
let p = d.add_page(100.0, 100.0);
|
||||
p.image_rgba(Rect::new(0.0, 0.0, 10.0, 10.0), &[255; 4 * 12], [4, 3]);
|
||||
assert!(p.images[0].alpha.is_none());
|
||||
let pdf = d.finish();
|
||||
assert!(find(&pdf, b"/SMask").is_none());
|
||||
assert_eq!(pdf.windows(15).filter(|w| *w == b"/Subtype /Image").count(), 1);
|
||||
|
||||
for translucent in [0usize, 5, 11] {
|
||||
let mut px = vec![255u8; 4 * 12];
|
||||
px[translucent * 4 + 3] = 7;
|
||||
let mut d = PdfDoc::new();
|
||||
d.add_page(100.0, 100.0).image_rgba(Rect::new(0.0, 0.0, 10.0, 10.0), &px, [4, 3]);
|
||||
let (objs, pages) = parse_pages(&d.finish());
|
||||
let (_, _, _, alpha) = decode_image(&objs, pages[0].xobjects[0].1);
|
||||
let mut want = vec![255u8; 12];
|
||||
want[translucent] = 7;
|
||||
assert_eq!(alpha, Some(want));
|
||||
}
|
||||
}
|
||||
|
||||
/// O4: the same picture on five pages is stored once (image + mask), by every page.
|
||||
#[test]
|
||||
fn identical_images_are_stored_once() -> Result<(), Box<dyn Error>> {
|
||||
let pics = pictures();
|
||||
let (px, w, h) = &pics[0]; // has alpha: image object + mask object
|
||||
let mut d = PdfDoc::new();
|
||||
for _ in 0..5 {
|
||||
let p = d.add_page(200.0, 200.0);
|
||||
p.image_rgba(Rect::new(1.0, 1.0, 20.0, 20.0), px, [*w, *h]);
|
||||
p.image_rgba(Rect::new(30.0, 1.0, 20.0, 20.0), px, [*w, *h]); // twice on one page
|
||||
}
|
||||
let pdf = d.finish();
|
||||
check_xref(&pdf)?;
|
||||
assert_eq!(pdf.windows(15).filter(|w| *w == b"/Subtype /Image").count(), 2, "one image + one soft mask");
|
||||
let (_, pages) = parse_pages(&pdf);
|
||||
let ids: Vec<usize> = pages.iter().flat_map(|p| p.xobjects.iter().map(|x| x.1)).collect();
|
||||
assert_eq!(ids.len(), 5, "one XObject entry per page");
|
||||
assert!(ids.iter().all(|i| *i == ids[0]));
|
||||
for p in &pages {
|
||||
assert_eq!(p.content.matches(" Do").count(), 2);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// O4: a picture that differs in one byte, or only in size, is not merged.
|
||||
#[test]
|
||||
fn different_images_are_not_merged() {
|
||||
let a = vec![50u8; 4 * 4];
|
||||
let mut b = a.clone();
|
||||
b[5] = 51;
|
||||
let mut d = PdfDoc::new();
|
||||
let p = d.add_page(100.0, 100.0);
|
||||
p.image_rgba(Rect::new(0.0, 0.0, 5.0, 5.0), &a, [4, 1]);
|
||||
p.image_rgba(Rect::new(0.0, 0.0, 5.0, 5.0), &b, [4, 1]);
|
||||
p.image_rgba(Rect::new(0.0, 0.0, 5.0, 5.0), &a, [2, 2]);
|
||||
d.add_page(100.0, 100.0).image_rgba(Rect::new(0.0, 0.0, 5.0, 5.0), &a, [4, 1]);
|
||||
let pdf = d.finish();
|
||||
assert_eq!(pdf.windows(15).filter(|w| *w == b"/Subtype /Image").count(), 6, "three distinct pictures, each with a soft mask (alpha 50)");
|
||||
}
|
||||
|
||||
/// O4: many distinct pictures on one page are looked up by hash, not by comparing each new
|
||||
/// picture with every earlier one (which made dense picture exports quadratic).
|
||||
#[test]
|
||||
fn many_distinct_images_on_one_page_are_not_compared_pairwise() {
|
||||
const N: u32 = 3000;
|
||||
let mut d = PdfDoc::new();
|
||||
let p = d.add_page(612.0, 792.0);
|
||||
for i in 0..N {
|
||||
let [a, b, c, _] = i.to_le_bytes();
|
||||
p.image_rgba(Rect::new(0.0, 0.0, 1.0, 1.0), &[a, b, c, 255], [1, 1]);
|
||||
}
|
||||
// A repeat of the first picture still shares its XObject.
|
||||
p.image_rgba(Rect::new(5.0, 5.0, 1.0, 1.0), &[0, 0, 0, 255], [1, 1]);
|
||||
assert_eq!(p.images.len(), N as usize);
|
||||
assert!(p.content.ends_with("/Im1 Do Q\n"), "the repeated picture reuses /Im1");
|
||||
assert!(p.image_comparisons <= 1, "{} pixel comparisons for {N} distinct pictures", p.image_comparisons);
|
||||
let pdf = d.finish();
|
||||
assert_eq!(pdf.windows(15).filter(|w| *w == b"/Subtype /Image").count(), N as usize);
|
||||
}
|
||||
|
||||
/// O4: equal hashes alone never merge images; the pixels are compared.
|
||||
#[test]
|
||||
fn hash_collisions_do_not_merge_images() {
|
||||
let mut d = PdfDoc::new();
|
||||
for v in [10u8, 20] {
|
||||
d.add_page(100.0, 100.0).image_rgba(Rect::new(0.0, 0.0, 5.0, 5.0), &[v; 16], [2, 2]);
|
||||
}
|
||||
for p in 0..2 {
|
||||
d.page_mut(p).unwrap().images[0].hash = 42; // forced collision
|
||||
}
|
||||
let (objs, pages) = parse_pages(&d.finish());
|
||||
let ids: Vec<usize> = pages.iter().map(|p| p.xobjects[0].1).collect();
|
||||
assert_ne!(ids[0], ids[1]);
|
||||
assert_eq!(decode_image(&objs, ids[0]).2, vec![10; 12]);
|
||||
assert_eq!(decode_image(&objs, ids[1]).2, vec![20; 12]);
|
||||
}
|
||||
|
||||
/// S5: the colour component table equals the old float formatting for every byte.
|
||||
#[test]
|
||||
fn colour_components_match_the_float_formatting() {
|
||||
for v in 0..=255u8 {
|
||||
let mut s = String::new();
|
||||
push_unit(&mut s, v);
|
||||
assert_eq!(s, n(v as f32 / 255.0), "byte {v}");
|
||||
}
|
||||
let mut s = String::new();
|
||||
push_rgb(&mut s, [255, 128, 0], true);
|
||||
assert_eq!(s, "1 0.502 0 RG");
|
||||
}
|
||||
|
||||
/// S3: the obsolete /ProcSet entry is gone.
|
||||
#[test]
|
||||
fn no_procset() {
|
||||
let (pdf, _) = corpus_doc(3);
|
||||
assert!(find(&pdf, b"/ProcSet").is_none());
|
||||
}
|
||||
|
||||
/// O3: a style that is already in effect is not emitted again.
|
||||
#[test]
|
||||
fn unchanged_style_is_emitted_once() {
|
||||
let mut d = PdfDoc::new();
|
||||
let p = d.add_page(200.0, 200.0);
|
||||
for i in 0..50 {
|
||||
p.rect_stroke(i as f32, 0.0, 5.0, 5.0, 1.5, [10, 20, 30]);
|
||||
}
|
||||
for i in 0..50 {
|
||||
p.stroke_line_dashed(Point::new(0.0, i as f32), Point::new(9.0, 9.0), 1.5, [10, 20, 30], &[2.0, 1.0]);
|
||||
}
|
||||
for _ in 0..50 {
|
||||
p.fill_rect(0.0, 0.0, 4.0, 4.0, [1, 2, 3]);
|
||||
p.text(5.0, 5.0, 9.0, Font::Helvetica, [1, 2, 3], "x");
|
||||
}
|
||||
let s = interpret(&p.content);
|
||||
assert_eq!(p.content.matches(" RG\n").count(), 1);
|
||||
assert_eq!(p.content.matches(" rg\n").count(), 1);
|
||||
assert_eq!(p.content.matches(" w\n").count(), 1);
|
||||
assert_eq!(p.content.matches(" d\n").count(), 2, "solid, then [2 1]");
|
||||
assert_eq!(s.style_ops, 5);
|
||||
assert_eq!(s.paints.len(), 50 * 4);
|
||||
}
|
||||
|
||||
/// O3: the tracked style follows q/Q, and a new page starts from scratch.
|
||||
#[test]
|
||||
fn style_tracking_follows_save_restore_and_pages() {
|
||||
let red = [255, 0, 0];
|
||||
let blue = [0, 0, 255];
|
||||
let mut d = PdfDoc::new();
|
||||
let p = d.add_page(100.0, 100.0);
|
||||
p.fill_rect(0.0, 0.0, 5.0, 5.0, red);
|
||||
p.save();
|
||||
p.fill_rect(0.0, 0.0, 5.0, 5.0, red); // still red inside: nothing to emit
|
||||
p.fill_rect(0.0, 0.0, 5.0, 5.0, blue);
|
||||
p.restore();
|
||||
p.fill_rect(0.0, 0.0, 5.0, 5.0, red); // red again after Q without a new operator
|
||||
p.fill_rect(0.0, 0.0, 5.0, 5.0, blue); // but blue must be re-emitted
|
||||
let fills: Vec<String> = interpret(&p.content).paints.iter().map(|p| p.fill.clone()).collect();
|
||||
assert_eq!(fills, ["1 0 0", "1 0 0", "0 0 1", "1 0 0", "0 0 1"]);
|
||||
assert_eq!(p.content.matches(" rg\n").count(), 3, "red once, blue twice");
|
||||
let p2 = d.add_page(100.0, 100.0);
|
||||
p2.fill_rect(0.0, 0.0, 5.0, 5.0, red);
|
||||
assert_eq!(p2.content.matches(" rg\n").count(), 1, "a new page re-emits the colour");
|
||||
}
|
||||
|
||||
/// B6: only 28 nested saves are written, the matching extra restores are no-ops.
|
||||
#[test]
|
||||
fn save_nesting_is_limited_to_28() -> Result<(), Box<dyn Error>> {
|
||||
let mut d = PdfDoc::new();
|
||||
let p = d.add_page(100.0, 100.0);
|
||||
for _ in 0..40 {
|
||||
p.save();
|
||||
}
|
||||
p.fill_rect(0.0, 0.0, 5.0, 5.0, [1, 1, 1]);
|
||||
assert_eq!(p.content.matches("q\n").count(), 28);
|
||||
// The 12 restores that match refused saves do nothing...
|
||||
for _ in 0..12 {
|
||||
p.restore();
|
||||
}
|
||||
assert_eq!(p.content.matches("Q\n").count(), 0);
|
||||
assert_eq!(interpret(&p.content).max_depth, 28);
|
||||
// ...the next 28 undo the accepted ones, and further restores are ignored.
|
||||
for _ in 0..40 {
|
||||
p.restore();
|
||||
}
|
||||
assert_eq!(p.content.matches("Q\n").count(), 28);
|
||||
let i = interpret(&p.content);
|
||||
assert!(i.balanced);
|
||||
// An image needs its own q: dropped at the limit, drawn otherwise.
|
||||
for _ in 0..28 {
|
||||
p.save();
|
||||
}
|
||||
p.image_rgba(Rect::new(0.0, 0.0, 5.0, 5.0), &[9; 16], [2, 2]);
|
||||
assert!(!p.content.contains(" Do"));
|
||||
p.restore();
|
||||
p.image_rgba(Rect::new(0.0, 0.0, 5.0, 5.0), &[9; 16], [2, 2]);
|
||||
assert!(p.content.contains(" Do"));
|
||||
let pdf = d.finish();
|
||||
check_xref(&pdf)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// B6: `finish()` closes what is still open, without exceeding the limit.
|
||||
#[test]
|
||||
fn finish_closes_open_saves() -> Result<(), Box<dyn Error>> {
|
||||
let mut d = PdfDoc::new();
|
||||
let p = d.add_page(100.0, 100.0);
|
||||
for _ in 0..33 {
|
||||
p.save();
|
||||
}
|
||||
let (_, pages) = parse_pages(&d.finish());
|
||||
let i = interpret(&pages[0].content);
|
||||
assert!(i.balanced);
|
||||
assert_eq!(i.qs, 28);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// B7: round joins set for a polyline do not leak into later rectangles, lines and circles.
|
||||
#[test]
|
||||
fn join_does_not_leak_from_polylines() {
|
||||
let mut d = PdfDoc::new();
|
||||
let p = d.add_page(200.0, 200.0);
|
||||
let pts = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]];
|
||||
p.rect_stroke(0.0, 0.0, 5.0, 5.0, 1.0, [0, 0, 0]);
|
||||
p.polyline(&pts, 1.0, [0, 0, 0], &[]);
|
||||
p.polyline(&pts, 1.0, [0, 0, 0], &[]);
|
||||
p.rect_stroke(0.0, 0.0, 5.0, 5.0, 1.0, [0, 0, 0]);
|
||||
p.stroke_line(0.0, 0.0, 9.0, 9.0, 1.0, [0, 0, 0]);
|
||||
p.polygon(&pts, None, Some(([0, 0, 0], 1.0)));
|
||||
p.circle(50.0, 50.0, 5.0, None, Some(([0, 0, 0], 1.0)));
|
||||
let joins: Vec<String> = interpret(&p.content).paints.iter().map(|p| p.join.clone()).collect();
|
||||
assert_eq!(joins, ["0", "1", "1", "0", "0", "1", "0"]);
|
||||
assert_eq!(p.content.matches(" j\n").count(), 4, "1, 0, 1, 0: only on change");
|
||||
}
|
||||
|
||||
fn page_content(d: &PdfDoc) -> &str {
|
||||
&d.pages[0].content
|
||||
}
|
||||
@@ -210,7 +765,10 @@ fn all_zero_dash_is_drawn_solid() {
|
||||
p.polyline(&[[0.0, 0.0], [5.0, 5.0]], 1.0, [0, 0, 0], &[0.0, 0.0001, f32::NAN]);
|
||||
p.stroke_line_dashed(Point::new(0.0, 0.0), Point::new(50.0, 0.0), 1.0, [0, 0, 0], &[3.0, 2.0]);
|
||||
let c = page_content(&d);
|
||||
assert_eq!(c.matches("[] 0 d").count(), 2);
|
||||
// The writer emits style operators only on change: the two solid lines share one `[] 0 d`.
|
||||
let dashes: Vec<String> = interpret(c).paints.iter().map(|p| p.dash.clone()).collect();
|
||||
assert_eq!(dashes, ["", "", "3 2"]);
|
||||
assert_eq!(c.matches("[] 0 d").count(), 1);
|
||||
assert!(!c.contains("[0 0]"));
|
||||
assert!(c.contains("[3 2] 0 d"));
|
||||
}
|
||||
@@ -224,7 +782,9 @@ fn negative_and_nan_stroke_widths_become_zero() {
|
||||
p.polygon(&[[0.0, 0.0], [5.0, 0.0], [5.0, 5.0]], None, Some(([0, 0, 0], -1.0)));
|
||||
p.circle(20.0, 20.0, 5.0, None, Some(([0, 0, 0], f32::NEG_INFINITY)));
|
||||
let c = page_content(&d);
|
||||
assert_eq!(c.matches("\n0 w ").count(), 4);
|
||||
// Style operators are emitted only on change, so judge the width in effect for each stroke.
|
||||
let widths: Vec<String> = interpret(c).paints.iter().map(|p| p.width.clone()).collect();
|
||||
assert_eq!(widths, ["0", "0", "0", "0"]);
|
||||
assert!(!c.contains("-1 w") && !c.contains("-3 w"));
|
||||
}
|
||||
|
||||
@@ -333,6 +893,18 @@ fn cjk_text_is_not_replaced_by_question_marks() -> Result<(), Box<dyn Error>> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_alternating_runs_share_one_text_matrix() {
|
||||
let mut doc = PdfDoc::new();
|
||||
let p = doc.add_page(612.0, 792.0);
|
||||
p.text(10.0, 20.0, 10.0, Font::Helvetica, [0, 0, 0], &"a\u{4e2d}".repeat(2000));
|
||||
let pdf = doc.finish();
|
||||
let count = |needle: &[u8]| pdf.windows(needle.len()).filter(|w| *w == needle).count();
|
||||
assert_eq!(count(b" Tm\n"), 1, "the position is set once, before the first run");
|
||||
assert_eq!(count(b" Tf\n"), 4000, "every run selects its font");
|
||||
assert_eq!(count(b" Tj\n"), 4000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_font_draws_maths_in_its_own_encoding() {
|
||||
let mut doc = PdfDoc::new();
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
//! The content-stream emission of the writer before the cleanup (a verbatim copy of the old
|
||||
//! per-operator formatting, which re-emitted the full style for every operator, had no `q` limit
|
||||
//! and left `1 j` set after polylines), plus an interpreter that reduces a content stream to the
|
||||
//! effective graphics state of every painting operator.
|
||||
|
||||
use super::super::*;
|
||||
|
||||
fn old_rgb_op(c: Rgb, stroke: bool) -> String {
|
||||
let f = |v: u8| n(v as f32 / 255.0);
|
||||
format!("{} {} {} {}", f(c[0]), f(c[1]), f(c[2]), if stroke { "RG" } else { "rg" })
|
||||
}
|
||||
|
||||
/// The old content writer.
|
||||
#[derive(Default)]
|
||||
pub struct OldPage {
|
||||
pub height: f32,
|
||||
pub content: String,
|
||||
depth: u32,
|
||||
images: usize,
|
||||
}
|
||||
|
||||
impl OldPage {
|
||||
pub fn new(height: f32) -> OldPage {
|
||||
OldPage { height, ..Default::default() }
|
||||
}
|
||||
fn y(&self, y: f32) -> f32 {
|
||||
self.height - y
|
||||
}
|
||||
fn op(&mut self, s: &str) {
|
||||
self.content.push_str(s);
|
||||
self.content.push('\n');
|
||||
}
|
||||
pub fn save(&mut self) {
|
||||
self.depth += 1;
|
||||
self.content.push_str("q\n");
|
||||
}
|
||||
pub fn restore(&mut self) {
|
||||
if self.depth > 0 {
|
||||
self.depth -= 1;
|
||||
self.content.push_str("Q\n");
|
||||
}
|
||||
}
|
||||
pub fn clip_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
|
||||
let s = format!("{} {} {} {} re W n", n(x), n(self.y(y + h)), n(w.max(0.0)), n(h.max(0.0)));
|
||||
self.op(&s);
|
||||
}
|
||||
pub fn fill_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: Rgb) {
|
||||
if w <= 0.0 || h <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let s = format!("{}\n{} {} {} {} re f", old_rgb_op(color, false), n(x), n(self.y(y + h)), n(w), n(h));
|
||||
self.op(&s);
|
||||
}
|
||||
pub fn rect_stroke(&mut self, x: f32, y: f32, w: f32, h: f32, width: f32, color: Rgb) {
|
||||
let s = format!("{}\n{} w [] 0 d\n{} {} {} {} re S", old_rgb_op(color, true), n(width), n(x), n(self.y(y + h)), n(w), n(h));
|
||||
self.op(&s);
|
||||
}
|
||||
pub fn stroke_line_dashed(&mut self, start: Point, end: Point, width: f32, color: Rgb, dash: &[f32]) {
|
||||
let (x0, y0, x1, y1) = (start.x, start.y, end.x, end.y);
|
||||
let d: Vec<String> = dash.iter().take(8).map(|v| n(v.max(0.0))).collect();
|
||||
let s = format!(
|
||||
"{}\n{} w [{}] 0 d\n{} {} m {} {} l S",
|
||||
old_rgb_op(color, true),
|
||||
n(width),
|
||||
d.join(" "),
|
||||
n(x0),
|
||||
n(self.y(y0)),
|
||||
n(x1),
|
||||
n(self.y(y1))
|
||||
);
|
||||
self.op(&s);
|
||||
}
|
||||
pub fn polyline(&mut self, pts: &[[f32; 2]], width: f32, color: Rgb, dash: &[f32]) {
|
||||
if pts.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let d: Vec<String> = dash.iter().take(8).map(|v| n(v.max(0.0))).collect();
|
||||
let mut s = format!("{}\n{} w [{}] 0 d 1 j\n", old_rgb_op(color, true), n(width), d.join(" "));
|
||||
self.path(&mut s, pts, false);
|
||||
s.push_str(" S");
|
||||
self.op(&s);
|
||||
}
|
||||
fn path(&self, s: &mut String, pts: &[[f32; 2]], close: bool) {
|
||||
for (i, p) in pts.iter().enumerate() {
|
||||
let _ = write!(s, "{} {} {} ", n(p[0]), n(self.y(p[1])), if i == 0 { "m" } else { "l" });
|
||||
}
|
||||
if close {
|
||||
s.push('h');
|
||||
}
|
||||
}
|
||||
pub fn polygon(&mut self, pts: &[[f32; 2]], fill: Option<Rgb>, stroke: Option<(Rgb, f32)>) {
|
||||
if pts.len() < 2 || (fill.is_none() && stroke.is_none()) {
|
||||
return;
|
||||
}
|
||||
let mut s = String::new();
|
||||
if let Some(f) = fill {
|
||||
s.push_str(&old_rgb_op(f, false));
|
||||
s.push('\n');
|
||||
}
|
||||
if let Some((c, w)) = stroke {
|
||||
let _ = writeln!(s, "{}\n{} w [] 0 d 1 j", old_rgb_op(c, true), n(w));
|
||||
}
|
||||
self.path(&mut s, pts, true);
|
||||
s.push_str(match (fill.is_some(), stroke.is_some()) {
|
||||
(true, true) => " B",
|
||||
(true, false) => " f",
|
||||
_ => " S",
|
||||
});
|
||||
self.op(&s);
|
||||
}
|
||||
pub fn circle(&mut self, cx: f32, cy: f32, r: f32, fill: Option<Rgb>, stroke: Option<(Rgb, f32)>) {
|
||||
if r <= 0.0 || (fill.is_none() && stroke.is_none()) {
|
||||
return;
|
||||
}
|
||||
let y = self.y(cy);
|
||||
let mut s = String::new();
|
||||
if let Some(f) = fill {
|
||||
s.push_str(&old_rgb_op(f, false));
|
||||
s.push('\n');
|
||||
}
|
||||
if let Some((c, w)) = stroke {
|
||||
let _ = writeln!(s, "{}\n{} w [] 0 d", old_rgb_op(c, true), n(w));
|
||||
}
|
||||
const K: f32 = 0.552_284_8;
|
||||
let k = r * K;
|
||||
let _ = write!(
|
||||
s,
|
||||
"{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c h",
|
||||
n(cx + r),
|
||||
n(y),
|
||||
n(cx + r),
|
||||
n(y + k),
|
||||
n(cx + k),
|
||||
n(y + r),
|
||||
n(cx),
|
||||
n(y + r),
|
||||
n(cx - k),
|
||||
n(y + r),
|
||||
n(cx - r),
|
||||
n(y + k),
|
||||
n(cx - r),
|
||||
n(y),
|
||||
n(cx - r),
|
||||
n(y - k),
|
||||
n(cx - k),
|
||||
n(y - r),
|
||||
n(cx),
|
||||
n(y - r),
|
||||
n(cx + k),
|
||||
n(y - r),
|
||||
n(cx + r),
|
||||
n(y - k),
|
||||
n(cx + r),
|
||||
n(y)
|
||||
);
|
||||
s.push_str(match (fill.is_some(), stroke.is_some()) {
|
||||
(true, true) => " B",
|
||||
(true, false) => " f",
|
||||
_ => " S",
|
||||
});
|
||||
self.op(&s);
|
||||
}
|
||||
pub fn text_rotated(&mut self, origin: Point, size: f32, font: Font, color: Rgb, text: &str, angle: f32) {
|
||||
let (x, y) = (origin.x, origin.y);
|
||||
let fi = font.index();
|
||||
let (sn, cs) = angle.sin_cos();
|
||||
let s = format!(
|
||||
"BT\n{}\n/F{} {} Tf\n{} {} {} {} {} {} Tm\n{} Tj\nET",
|
||||
old_rgb_op(color, false),
|
||||
fi + 1,
|
||||
n(size),
|
||||
n(cs),
|
||||
n(-sn),
|
||||
n(sn),
|
||||
n(cs),
|
||||
n(x),
|
||||
n(self.y(y)),
|
||||
pdf_string(text)
|
||||
);
|
||||
self.op(&s);
|
||||
}
|
||||
pub fn image(&mut self, x: f32, y: f32, w: f32, h: f32) {
|
||||
self.images += 1;
|
||||
let s = format!("q {} 0 0 {} {} {} cm /Im{} Do Q", n(w), n(h), n(x), n(self.y(y + h)), self.images);
|
||||
self.op(&s);
|
||||
}
|
||||
}
|
||||
|
||||
/// The effective state at one painting operator. Fields that cannot influence the operator
|
||||
/// (the stroke state of a fill, the fill of a stroke) are left empty.
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub struct Paint {
|
||||
pub op: String,
|
||||
/// Path construction / text operands since the previous painting operator.
|
||||
pub geometry: String,
|
||||
pub fill: String,
|
||||
pub stroke: String,
|
||||
pub width: String,
|
||||
pub dash: String,
|
||||
pub join: String,
|
||||
/// Clip paths in effect (innermost last).
|
||||
pub clip: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct Interp {
|
||||
pub paints: Vec<Paint>,
|
||||
pub max_depth: usize,
|
||||
/// Number of `q` operators.
|
||||
pub qs: usize,
|
||||
/// Number of colour/width/dash/join operators.
|
||||
pub style_ops: usize,
|
||||
pub balanced: bool,
|
||||
}
|
||||
|
||||
fn tokens(content: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur = String::new();
|
||||
let mut chars = content.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'(' => {
|
||||
if !cur.is_empty() {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
}
|
||||
let mut s = String::from("(");
|
||||
while let Some(d) = chars.next() {
|
||||
s.push(d);
|
||||
if d == '\\' {
|
||||
if let Some(e) = chars.next() {
|
||||
s.push(e);
|
||||
}
|
||||
} else if d == ')' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.push(s);
|
||||
}
|
||||
'[' | ']' => {
|
||||
if !cur.is_empty() {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
}
|
||||
out.push(c.to_string());
|
||||
}
|
||||
c if c.is_whitespace() => {
|
||||
if !cur.is_empty() {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
}
|
||||
}
|
||||
c => cur.push(c),
|
||||
}
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
out.push(cur);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct State {
|
||||
fill: String,
|
||||
stroke: String,
|
||||
width: String,
|
||||
dash: String,
|
||||
join: String,
|
||||
clip: Vec<String>,
|
||||
}
|
||||
|
||||
/// Interprets a content stream: tracks q/Q, colours, width, dash, join and clips.
|
||||
pub fn interpret(content: &str) -> Interp {
|
||||
let mut st = State { fill: "0 0 0".into(), stroke: "0 0 0".into(), width: "1".into(), dash: String::new(), join: "0".into(), clip: Vec::new() };
|
||||
let mut stack: Vec<State> = Vec::new();
|
||||
let mut r = Interp { balanced: true, ..Default::default() };
|
||||
let mut operands: Vec<String> = Vec::new();
|
||||
let mut path: Vec<String> = Vec::new();
|
||||
let mut pending_clip = false;
|
||||
for t in tokens(content) {
|
||||
let is_op = t.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '*') && !t.starts_with('/');
|
||||
if !is_op {
|
||||
operands.push(t);
|
||||
continue;
|
||||
}
|
||||
let ops = std::mem::take(&mut operands);
|
||||
match t.as_str() {
|
||||
"q" => {
|
||||
stack.push(st.clone());
|
||||
r.qs += 1;
|
||||
r.max_depth = r.max_depth.max(stack.len());
|
||||
}
|
||||
"Q" => match stack.pop() {
|
||||
Some(s) => st = s,
|
||||
None => r.balanced = false,
|
||||
},
|
||||
"rg" => {
|
||||
st.fill = ops.join(" ");
|
||||
r.style_ops += 1;
|
||||
}
|
||||
"RG" => {
|
||||
st.stroke = ops.join(" ");
|
||||
r.style_ops += 1;
|
||||
}
|
||||
"w" => {
|
||||
st.width = ops.join(" ");
|
||||
r.style_ops += 1;
|
||||
}
|
||||
"d" => {
|
||||
// `[a b] 0` -> `a b`
|
||||
st.dash = ops.iter().take_while(|o| *o != "]").skip(1).cloned().collect::<Vec<_>>().join(" ");
|
||||
r.style_ops += 1;
|
||||
}
|
||||
"j" => {
|
||||
st.join = ops.join(" ");
|
||||
r.style_ops += 1;
|
||||
}
|
||||
"re" | "m" | "l" | "c" | "h" | "cm" | "Tf" | "Tm" => {
|
||||
path.push(format!("{} {}", ops.join(" "), t));
|
||||
}
|
||||
"BT" | "ET" => {}
|
||||
"W" => pending_clip = true,
|
||||
"n" => {
|
||||
if pending_clip {
|
||||
st.clip.push(path.join(" "));
|
||||
pending_clip = false;
|
||||
}
|
||||
path.clear();
|
||||
}
|
||||
"Tj" => {
|
||||
path.push(format!("{} {}", ops.join(" "), t));
|
||||
r.paints.push(Paint { op: t.clone(), geometry: path.join(" "), fill: st.fill.clone(), clip: st.clip.clone(), ..Default::default() });
|
||||
path.clear();
|
||||
}
|
||||
"f" | "S" | "B" | "Do" => {
|
||||
let mut p = Paint { op: t.clone(), geometry: path.join(" "), clip: st.clip.clone(), ..Default::default() };
|
||||
if t == "f" || t == "B" {
|
||||
p.fill = st.fill.clone();
|
||||
}
|
||||
if t == "S" || t == "B" {
|
||||
p.stroke = st.stroke.clone();
|
||||
p.width = st.width.clone();
|
||||
p.dash = st.dash.clone();
|
||||
p.join = st.join.clone();
|
||||
}
|
||||
r.paints.push(p);
|
||||
path.clear();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
r.balanced &= stack.is_empty();
|
||||
r
|
||||
}
|
||||
@@ -11,7 +11,7 @@ workspace = true
|
||||
[dependencies]
|
||||
# The shared capture slot (`craft_ui::capture`) comes from the one craft-libs revision every
|
||||
# craft-libs dependency uses (see the root Cargo.toml).
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "aa84415bae3b765fef6b7ef42404f66ad1cff9d9" }
|
||||
gridcraft-engine = { workspace = true }
|
||||
gridcraft-chart = { workspace = true }
|
||||
# `monochrome_emoji_fonts` restores the Noto Emoji and emoji-icon faces that 0.37 moved behind
|
||||
|
||||
@@ -71,6 +71,7 @@ impl SheetApp {
|
||||
capture_geometry: None,
|
||||
capture_geometry_from_raw: false,
|
||||
selected_object: None,
|
||||
chart_cache: Default::default(),
|
||||
clipboard_text_at_copy: None,
|
||||
cut_text: None,
|
||||
tab_run: None,
|
||||
@@ -114,6 +115,7 @@ impl SheetApp {
|
||||
// Floating pictures' textures go with their workbook, even when no sheet is painted.
|
||||
let open: Vec<u64> = self.session.documents().iter().map(|d| d.uid).collect();
|
||||
floating_pictures::forget_closed(ctx, &open);
|
||||
self.chart_cache.get_mut().unwrap_or_else(std::sync::PoisonError::into_inner).retain_docs(open.iter().copied());
|
||||
self.apply_font_state(ctx);
|
||||
if let Some(answer) = self.desktop_theme.take_change() {
|
||||
self.desktop_dark = Some(answer);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::{
|
||||
INTERFACE_SCALE_MAX, INTERFACE_SCALE_MIN, Json, NoticeKind, SheetApp, UiRequest, backstage, clamp_interface_scale, clean_error, dialogs,
|
||||
INTERFACE_SCALE_MAX, INTERFACE_SCALE_MIN, Json, Notice, NoticeKind, SheetApp, UiRequest, backstage, clamp_interface_scale, clean_error, dialogs,
|
||||
formula_locale, i18n, json, now_ms, printing,
|
||||
};
|
||||
|
||||
@@ -96,6 +96,21 @@ impl SheetApp {
|
||||
{
|
||||
self.deliver_pdf(r);
|
||||
}
|
||||
// The scan stopped at its cell allowance: say that some cells were not checked.
|
||||
if id == "data.circleInvalid"
|
||||
&& let Ok(r) = &r
|
||||
&& r.get("truncated").and_then(Json::as_bool) == Some(true)
|
||||
{
|
||||
let scan = "The scan stopped after 100,000 cell visits; some validated cells were not checked for invalid data.";
|
||||
// An open-time warning that is still shown keeps its text and action; the scan note joins it.
|
||||
match self.notice.as_mut() {
|
||||
Some(n) => {
|
||||
n.text = format!("{} {scan}", n.text);
|
||||
n.kind = NoticeKind::Warning;
|
||||
}
|
||||
None => self.notice = Some(Notice { text: scan.into(), kind: NoticeKind::Warning, action: None }),
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
|
||||
@@ -409,7 +409,11 @@ impl SheetApp {
|
||||
|
||||
/// Enter/Tab move inside a multi-cell selection, otherwise to the next cell.
|
||||
pub fn move_after_enter(&mut self, dr: i64, dc: i64) {
|
||||
let multi = self.session.active().is_some_and(|d| !d.selection.is_single_cell());
|
||||
// A selection that is just one merged cell moves like one cell (Enter leaves it).
|
||||
let multi = self
|
||||
.session
|
||||
.active()
|
||||
.is_some_and(|d| !d.selection.is_single_cell() && !d.wb.active().is_some_and(|sh| d.selection.is_one_merged_cell(sh)));
|
||||
// Any other move (arrows, clicks, another sheet) leaves the selection off the cell the
|
||||
// last Tab landed on, which ends the run.
|
||||
let here = self.session.active().map(|d| (d.uid, d.wb.active_sheet(), d.selection.active));
|
||||
@@ -508,3 +512,61 @@ mod protected_cell_tests {
|
||||
assert!(app.run("file.saveBytes", json!({"format": "xlsx"})).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod merged_cell_enter_tests {
|
||||
use super::*;
|
||||
use gridcraft_engine::Session;
|
||||
|
||||
#[test]
|
||||
fn enter_and_tab_leave_a_selected_merged_cell_and_keep_the_tab_run() {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut app = SheetApp::new(session, Default::default());
|
||||
app.run("selection.set", json!({"range": "B2:C3"})).unwrap();
|
||||
app.run("home.mergeCells", json!({})).unwrap();
|
||||
let sel = |app: &SheetApp| app.session.active().map(|d| d.selection.a1()).unwrap_or_default();
|
||||
for ((dr, dc), want) in [((1, 0), "B4"), ((-1, 0), "B1"), ((0, 1), "D2"), ((0, -1), "A2")] {
|
||||
app.run("selection.set", json!({"cell": "B2"})).unwrap();
|
||||
assert_eq!(sel(&app), "B2:C3");
|
||||
app.move_after_enter(dr, dc);
|
||||
assert_eq!(sel(&app), want, "Enter/Tab ({dr},{dc}) from the merged cell B2:C3");
|
||||
}
|
||||
// A Tab run through the merged cell goes on past it: Enter then goes back to the column
|
||||
// the run started in, on the next row.
|
||||
app.tab_run = None; // the Shift+Tab above ended on A2 and started a run there
|
||||
app.run("selection.set", json!({"cell": "A2"})).unwrap();
|
||||
app.move_after_enter(0, 1);
|
||||
assert_eq!(sel(&app), "B2:C3");
|
||||
app.move_after_enter(0, 1);
|
||||
assert_eq!(sel(&app), "D2");
|
||||
app.move_after_enter(1, 0);
|
||||
assert_eq!(sel(&app), "A3");
|
||||
// Enter straight from the merged cell in a Tab run leaves it below its bottom edge and
|
||||
// still goes back to the run's start column.
|
||||
app.tab_run = None;
|
||||
app.run("selection.set", json!({"cell": "A2"})).unwrap();
|
||||
app.move_after_enter(0, 1);
|
||||
assert_eq!(sel(&app), "B2:C3");
|
||||
app.move_after_enter(1, 0);
|
||||
assert_eq!(sel(&app), "A4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_from_a_merged_cell_in_a_tab_run_skips_a_hidden_row_and_keeps_the_column() {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
let mut app = SheetApp::new(session, Default::default());
|
||||
app.run("selection.set", json!({"range": "D2:E3"})).unwrap();
|
||||
app.run("home.mergeCells", json!({})).unwrap();
|
||||
app.run("selection.set", json!({"range": "A4"})).unwrap();
|
||||
app.run("home.hideRows", json!({})).unwrap();
|
||||
let sel = |app: &SheetApp| app.session.active().map(|d| d.selection.a1()).unwrap_or_default();
|
||||
app.run("selection.set", json!({"cell": "C2"})).unwrap();
|
||||
app.move_after_enter(0, 1);
|
||||
assert_eq!(sel(&app), "D2:E3");
|
||||
// Row 4 is hidden: Enter lands on C5, the run's column, not B5.
|
||||
app.move_after_enter(1, 0);
|
||||
assert_eq!(sel(&app), "C5");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ impl SheetApp {
|
||||
} else if self.fonts_rebind {
|
||||
// A font-set change made last frame is active now.
|
||||
self.fonts_rebind = false;
|
||||
self.chart_cache.get_mut().unwrap_or_else(std::sync::PoisonError::into_inner).clear();
|
||||
} else if !defer_font_change && !self.prepared_fonts && fonts::scan_done() {
|
||||
self.rebind_workbook_fonts(ctx);
|
||||
}
|
||||
@@ -75,6 +76,8 @@ impl SheetApp {
|
||||
fn start_font_setup(&mut self, ctx: &egui::Context) {
|
||||
if self.fonts_set {
|
||||
self.fonts_ready = true;
|
||||
// The new set is active now: chart text was measured with the old one.
|
||||
self.chart_cache.get_mut().unwrap_or_else(std::sync::PoisonError::into_inner).clear();
|
||||
return;
|
||||
}
|
||||
if self.prepared_fonts {
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
//! Rendered charts kept between frames.
|
||||
//!
|
||||
//! Resolving a chart's series and laying it out is O(points). Doing it for every visible chart on
|
||||
//! every frame kept the UI thread busy while scrolling a workbook with a few big charts (#1139,
|
||||
//! part of #1098). The primitives a chart renders to depend on the workbook, the chart's place in
|
||||
//! it, the zoom, the screen's pixels-per-point and the fonts text is measured with, so those are
|
||||
//! what an entry is checked against.
|
||||
//!
|
||||
//! **Why "the workbook is unchanged" can be known without a change counter.** A document's
|
||||
//! workbook is an `Arc<Workbook>` and its content is only reachable for writing through
|
||||
//! `Arc::make_mut` (or by replacing the `Arc`), because the model has no interior mutability and
|
||||
//! the code base forbids `unsafe`. While a `Weak` to the allocation exists, `make_mut` never
|
||||
//! mutates it in place: it clones (other strong owners, e.g. an undo snapshot) or moves the value
|
||||
//! to a new allocation (the `Weak` is cut loose). So if the document's current `Arc` still points
|
||||
//! at the allocation the cache holds a `Weak` to, nothing was written since the entries were
|
||||
//! built, however the write would have been made (command, undo, redo, recalculation, file load,
|
||||
//! a direct edit from the UI, a future code path). The `Weak` also keeps the allocation from being
|
||||
//! freed and reused, so a pointer match cannot be a stranger that landed on the same address.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
use gridcraft_chart::Prim;
|
||||
use gridcraft_engine::model::Workbook;
|
||||
|
||||
/// Most charts kept, over all documents.
|
||||
const MAX_ENTRIES: usize = 64;
|
||||
/// Most memory kept (estimated from the primitives), over all documents.
|
||||
const MAX_BYTES: usize = 48 << 20;
|
||||
|
||||
struct Entry {
|
||||
/// Zoom and pixels-per-point the primitives were measured for (`f32::to_bits`).
|
||||
z: u32,
|
||||
ppp: u32,
|
||||
prims: Arc<[Prim]>,
|
||||
bytes: usize,
|
||||
/// Tick of the last use; the smallest is evicted first.
|
||||
used: u64,
|
||||
}
|
||||
|
||||
struct DocCharts {
|
||||
/// The workbook the entries were built from.
|
||||
wb: Weak<Workbook>,
|
||||
/// (sheet index, index in the sheet's `charts`). The workbook is unchanged while the entries
|
||||
/// live, so the pair names one chart.
|
||||
charts: HashMap<(usize, usize), Entry>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct ChartCache {
|
||||
docs: HashMap<u64, DocCharts>,
|
||||
tick: u64,
|
||||
bytes: usize,
|
||||
/// Charts built (resolved and rendered) so far; for tests.
|
||||
#[cfg(test)]
|
||||
pub(crate) builds: usize,
|
||||
}
|
||||
|
||||
fn prim_bytes(p: &Prim) -> usize {
|
||||
std::mem::size_of::<Prim>()
|
||||
+ match p {
|
||||
Prim::Line { pts, .. } | Prim::Polygon { pts, .. } => pts.len().saturating_mul(std::mem::size_of::<[f32; 2]>()),
|
||||
Prim::Text { text, .. } => text.len(),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
impl ChartCache {
|
||||
/// The primitives of chart `index` of `sheet` in document `uid` whose workbook is `wb`, for
|
||||
/// zoom and pixels per point `(z, ppp)`. `build` renders the chart when there is no valid entry.
|
||||
pub(crate) fn get(
|
||||
&mut self,
|
||||
uid: u64,
|
||||
wb: &Arc<Workbook>,
|
||||
sheet: usize,
|
||||
index: usize,
|
||||
(z, ppp): (f32, f32),
|
||||
build: impl FnOnce() -> Vec<Prim>,
|
||||
) -> Arc<[Prim]> {
|
||||
self.tick = self.tick.wrapping_add(1);
|
||||
let tick = self.tick;
|
||||
let doc = self.docs.entry(uid).or_insert_with(|| DocCharts { wb: Arc::downgrade(wb), charts: HashMap::new() });
|
||||
if !std::ptr::eq(doc.wb.as_ptr(), Arc::as_ptr(wb)) {
|
||||
// The document changed (or is another one with this uid): nothing built from the old
|
||||
// workbook can be trusted.
|
||||
let dropped: usize = doc.charts.values().map(|e| e.bytes).sum();
|
||||
doc.charts.clear();
|
||||
doc.wb = Arc::downgrade(wb);
|
||||
self.bytes = self.bytes.saturating_sub(dropped);
|
||||
}
|
||||
let (zb, pb) = (z.to_bits(), ppp.to_bits());
|
||||
if let Some(e) = doc.charts.get_mut(&(sheet, index))
|
||||
&& e.z == zb
|
||||
&& e.ppp == pb
|
||||
{
|
||||
e.used = tick;
|
||||
return e.prims.clone();
|
||||
}
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.builds += 1;
|
||||
}
|
||||
let prims: Arc<[Prim]> = build().into();
|
||||
let bytes = prims.iter().map(prim_bytes).fold(0usize, usize::saturating_add);
|
||||
let Some(doc) = self.docs.get_mut(&uid) else { return prims };
|
||||
if let Some(old) = doc.charts.remove(&(sheet, index)) {
|
||||
self.bytes = self.bytes.saturating_sub(old.bytes);
|
||||
}
|
||||
// A chart that alone would fill a quarter of the budget is not worth keeping.
|
||||
if bytes <= MAX_BYTES / 4 {
|
||||
doc.charts.insert((sheet, index), Entry { z: zb, ppp: pb, prims: prims.clone(), bytes, used: tick });
|
||||
self.bytes = self.bytes.saturating_add(bytes);
|
||||
self.evict();
|
||||
}
|
||||
prims
|
||||
}
|
||||
|
||||
/// Drops least recently used entries until the caps hold.
|
||||
fn evict(&mut self) {
|
||||
loop {
|
||||
let count: usize = self.docs.values().map(|d| d.charts.len()).sum();
|
||||
if count <= MAX_ENTRIES && self.bytes <= MAX_BYTES {
|
||||
return;
|
||||
}
|
||||
let oldest = self.docs.iter().flat_map(|(uid, d)| d.charts.iter().map(move |(k, e)| (e.used, *uid, *k))).min();
|
||||
let Some((_, uid, key)) = oldest else { return };
|
||||
if let Some(e) = self.docs.get_mut(&uid).and_then(|d| d.charts.remove(&key)) {
|
||||
self.bytes = self.bytes.saturating_sub(e.bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Forgets the documents that are not in `live` (closed workbooks).
|
||||
pub(crate) fn retain_docs(&mut self, live: impl Iterator<Item = u64>) {
|
||||
let live: Vec<u64> = live.collect();
|
||||
let bytes = &mut self.bytes;
|
||||
self.docs.retain(|uid, d| {
|
||||
let keep = live.contains(uid);
|
||||
if !keep {
|
||||
*bytes = bytes.saturating_sub(d.charts.values().map(|e| e.bytes).sum());
|
||||
}
|
||||
keep
|
||||
});
|
||||
}
|
||||
|
||||
/// Forgets everything (the fonts text was measured with changed).
|
||||
pub(crate) fn clear(&mut self) {
|
||||
self.docs.clear();
|
||||
self.bytes = 0;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn len(&self) -> usize {
|
||||
self.docs.values().map(|d| d.charts.len()).sum()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn docs(&self) -> usize {
|
||||
self.docs.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::SheetApp;
|
||||
use crate::chartview::tests::RENDERS;
|
||||
use egui::{Event, vec2};
|
||||
use gridcraft_chart::ApproxMeasure;
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
type Harness = egui_kittest::Harness<'static, SheetApp>;
|
||||
|
||||
fn wb() -> Arc<Workbook> {
|
||||
Arc::new(Workbook::new())
|
||||
}
|
||||
|
||||
fn rect_prim(w: f32) -> Vec<Prim> {
|
||||
vec![Prim::Rect { x: 0.0, y: 0.0, w, h: 1.0, fill: None, stroke: None, radius: 0.0 }]
|
||||
}
|
||||
|
||||
fn line_prim(points: usize) -> Vec<Prim> {
|
||||
vec![Prim::Line { pts: vec![[0.0, 0.0]; points], color: [0, 0, 0, 255], width: 1.0, dash: false }]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_workbook_written_after_caching_is_never_taken_for_the_cached_one() {
|
||||
let mut cache = ChartCache::default();
|
||||
let mut w = wb();
|
||||
let build = || rect_prim(1.0);
|
||||
cache.get(1, &w, 0, 0, (1.0, 1.0), build);
|
||||
cache.get(1, &w, 0, 0, (1.0, 1.0), build);
|
||||
assert_eq!(cache.builds, 1, "same workbook: a hit");
|
||||
// Sole owner of the Arc: without the Weak this write would happen in place, at the same address.
|
||||
Arc::make_mut(&mut w).active_sheet.set(0);
|
||||
cache.get(1, &w, 0, 0, (1.0, 1.0), build);
|
||||
assert_eq!(cache.builds, 2, "a write through make_mut moved the workbook to a new allocation");
|
||||
// Shared with an undo snapshot: make_mut clones, the pointer changes as well.
|
||||
let snapshot = w.clone();
|
||||
Arc::make_mut(&mut w).active_sheet.set(0);
|
||||
cache.get(1, &w, 0, 0, (1.0, 1.0), build);
|
||||
assert_eq!(cache.builds, 3);
|
||||
// Replacing the Arc with an equal workbook (a reload) is a change too.
|
||||
w = Arc::new((*snapshot).clone());
|
||||
cache.get(1, &w, 0, 0, (1.0, 1.0), build);
|
||||
assert_eq!(cache.builds, 4);
|
||||
// The old allocation is still held by the cache's Weak while its owner is gone, so a new
|
||||
// workbook can't be mistaken for it by landing on the same address.
|
||||
drop(snapshot);
|
||||
cache.get(1, &w, 0, 0, (1.0, 1.0), build);
|
||||
assert_eq!(cache.builds, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_scale_chart_and_document_are_part_of_the_key() {
|
||||
let mut cache = ChartCache::default();
|
||||
let w = wb();
|
||||
let build = || rect_prim(1.0);
|
||||
cache.get(1, &w, 0, 0, (1.0, 1.0), build);
|
||||
cache.get(1, &w, 0, 0, (1.25, 1.0), build);
|
||||
cache.get(1, &w, 0, 0, (1.25, 2.0), build);
|
||||
cache.get(1, &w, 0, 1, (1.25, 2.0), build);
|
||||
cache.get(1, &w, 1, 0, (1.25, 2.0), build);
|
||||
cache.get(2, &w, 0, 0, (1.25, 2.0), build);
|
||||
assert_eq!(cache.builds, 6);
|
||||
cache.get(1, &w, 0, 1, (1.25, 2.0), build);
|
||||
cache.get(2, &w, 0, 0, (1.25, 2.0), build);
|
||||
assert_eq!(cache.builds, 6, "each of those is cached");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cache_is_capped_in_entries_and_bytes_and_drops_the_least_recently_used() {
|
||||
let mut cache = ChartCache::default();
|
||||
let w = wb();
|
||||
for i in 0..MAX_ENTRIES + 10 {
|
||||
cache.get(1, &w, 0, i, (1.0, 1.0), || rect_prim(1.0));
|
||||
// Chart 0 is used all the time, so it survives.
|
||||
cache.get(1, &w, 0, 0, (1.0, 1.0), || rect_prim(1.0));
|
||||
}
|
||||
assert_eq!(cache.len(), MAX_ENTRIES);
|
||||
let before = cache.builds;
|
||||
cache.get(1, &w, 0, 0, (1.0, 1.0), || rect_prim(1.0));
|
||||
assert_eq!(cache.builds, before, "the busy chart is still there");
|
||||
cache.get(1, &w, 0, 1, (1.0, 1.0), || rect_prim(1.0));
|
||||
assert_eq!(cache.builds, before + 1, "the idle one was evicted");
|
||||
|
||||
// Bytes: each of these is ~4 MiB of points; only a handful fit.
|
||||
let mut cache = ChartCache::default();
|
||||
let points = (4 << 20) / std::mem::size_of::<[f32; 2]>();
|
||||
for i in 0..40 {
|
||||
cache.get(1, &w, 0, i, (1.0, 1.0), || line_prim(points));
|
||||
}
|
||||
assert!(cache.bytes <= MAX_BYTES && cache.len() < 40, "{} entries, {} bytes", cache.len(), cache.bytes);
|
||||
// One that would fill a quarter of the budget is not kept, and is still returned.
|
||||
let mut cache = ChartCache::default();
|
||||
let big = cache.get(1, &w, 0, 0, (1.0, 1.0), || line_prim(MAX_BYTES / 4 / std::mem::size_of::<[f32; 2]>() + 1000));
|
||||
assert!(!big.is_empty());
|
||||
assert_eq!((cache.len(), cache.bytes), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_documents_are_forgotten() {
|
||||
let mut cache = ChartCache::default();
|
||||
let w = wb();
|
||||
cache.get(1, &w, 0, 0, (1.0, 1.0), || line_prim(10));
|
||||
cache.get(2, &w, 0, 0, (1.0, 1.0), || line_prim(10));
|
||||
cache.retain_docs([2].into_iter());
|
||||
assert_eq!((cache.docs(), cache.len()), (1, 1));
|
||||
cache.retain_docs(std::iter::empty());
|
||||
assert_eq!((cache.docs(), cache.len(), cache.bytes), (0, 0, 0));
|
||||
}
|
||||
|
||||
/// A workbook with charts of several kinds over numbers, dates and categories.
|
||||
fn corpus() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Date", "Sales", "Cost", "Share"]]})).unwrap();
|
||||
for i in 0..40u32 {
|
||||
let date = format!("2024-{:02}-{:02}", 1 + i / 28, 1 + i % 28);
|
||||
s.execute("cell.set", json!({"cell": format!("A{}", i + 2), "input": date})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": format!("B{}", i + 2), "input": format!("{}", (i * 37) % 101)})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": format!("C{}", i + 2), "input": format!("=B{}/2+{}", i + 2, i)})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": format!("D{}", i + 2), "input": format!("{}", (i % 5) + 1)})).unwrap();
|
||||
}
|
||||
for (i, (kind, range)) in [
|
||||
("column", "A1:C41"),
|
||||
("line", "A1:C41"),
|
||||
("scatter", "B1:C41"),
|
||||
("pie", "A1:B6"),
|
||||
("combo", "A1:C41"),
|
||||
("area", "A1:B41"),
|
||||
("bar", "A1:D41"),
|
||||
("doughnut", "A1:B6"),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
s.execute("insert.chart", json!({"range": range, "type": kind, "at": format!("H{}", 2 + i * 16), "width": 320, "height": 220})).unwrap();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_output_equals_a_fresh_render_for_a_corpus_of_charts() {
|
||||
let s = corpus();
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.active().unwrap();
|
||||
assert_eq!(sh.charts.len(), 8);
|
||||
let mut cache = ChartCache::default();
|
||||
for (i, ch) in sh.charts.iter().enumerate() {
|
||||
let fresh = || {
|
||||
let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet.get(), ch);
|
||||
gridcraft_chart::render(ch, &data, ch.anchor.width, ch.anchor.height, &ApproxMeasure)
|
||||
};
|
||||
let expect = fresh();
|
||||
assert!(expect.len() > 3, "{:?} renders something", ch.kind);
|
||||
let first = cache.get(d.uid, &d.wb, d.wb.active_sheet.get(), i, (1.0, 1.0), fresh);
|
||||
let second = cache.get(d.uid, &d.wb, d.wb.active_sheet.get(), i, (1.0, 1.0), || panic!("must be a hit"));
|
||||
assert_eq!(&*first, &expect[..], "{:?}: built entry", ch.kind);
|
||||
assert_eq!(&*second, &expect[..], "{:?}: cached entry", ch.kind);
|
||||
}
|
||||
assert_eq!(cache.builds, 8);
|
||||
}
|
||||
|
||||
// --- frames ---
|
||||
|
||||
fn harness(s: Session) -> Harness {
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut SheetApp| {
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
SheetApp::new(s, Default::default()),
|
||||
);
|
||||
h.run_steps(8);
|
||||
h
|
||||
}
|
||||
|
||||
/// Two charts side by side, both in view, over A1:B12 (B3 is `=B2*2`).
|
||||
fn two_charts() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Day", "Sales"]]})).unwrap();
|
||||
for i in 2..=12u32 {
|
||||
let input = if i == 3 { "=B2*2".to_string() } else { format!("{}", i * 3 % 17) };
|
||||
s.execute("cell.set", json!({"cell": format!("A{i}"), "input": format!("2024-03-{i:02}")})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": format!("B{i}"), "input": input})).unwrap();
|
||||
}
|
||||
s.execute("insert.chart", json!({"range": "A1:B12", "type": "column", "at": "D2", "width": 300, "height": 200})).unwrap();
|
||||
s.execute("insert.chart", json!({"range": "A1:B12", "type": "scatter", "at": "L2", "width": 300, "height": 200})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn builds() -> usize {
|
||||
RENDERS.with(|c| c.get())
|
||||
}
|
||||
|
||||
/// Runs `change`, lets a few frames pass and returns how many charts were rendered meanwhile.
|
||||
fn rendered_after(h: &mut Harness, change: impl FnOnce(&mut Harness)) -> usize {
|
||||
let before = builds();
|
||||
change(h);
|
||||
h.run_steps(4);
|
||||
builds() - before
|
||||
}
|
||||
|
||||
fn exec(h: &mut Harness, id: &str, params: serde_json::Value) {
|
||||
h.state_mut().session.execute(id, params).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_without_a_change_render_each_chart_once() {
|
||||
let mut h = harness(two_charts());
|
||||
assert!(builds() >= 2, "the charts were drawn");
|
||||
let n = rendered_after(&mut h, |h| h.run_steps(30));
|
||||
assert_eq!(n, 0, "30 idle frames render nothing again");
|
||||
// Pointer movement and wheel scrolling (view state only) change no workbook.
|
||||
let scroll_before = h.state().view().scroll;
|
||||
let n = rendered_after(&mut h, |h| {
|
||||
for i in 0..10 {
|
||||
h.input_mut().events.push(Event::PointerMoved(egui::pos2(300.0 + i as f32 * 7.0, 400.0)));
|
||||
h.input_mut().events.push(Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
delta: vec2(0.0, -2.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
});
|
||||
h.run_steps(1);
|
||||
}
|
||||
});
|
||||
assert_ne!(h.state().view().scroll, scroll_before, "the sheet did scroll");
|
||||
assert_eq!(n, 0, "hover and scrolling reuse the rendered charts");
|
||||
assert_eq!(h.state().chart_cache.lock().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_kind_of_change_renders_the_charts_again_exactly_once() {
|
||||
let mut h = harness(two_charts());
|
||||
type Case<'a> = (&'a str, Box<dyn Fn(&mut Harness)>);
|
||||
let cases: Vec<Case> = vec![
|
||||
("cell edit", Box::new(|h| exec(h, "cell.set", json!({"cell": "B5", "input": "99"})))),
|
||||
("recalc through a dependency", Box::new(|h| exec(h, "cell.set", json!({"cell": "B2", "input": "7"})))),
|
||||
("calculate now", Box::new(|h| exec(h, "formulas.calculateNow", json!({})))),
|
||||
("undo", Box::new(|h| exec(h, "edit.undo", json!({})))),
|
||||
("redo", Box::new(|h| exec(h, "edit.redo", json!({})))),
|
||||
("row hide", Box::new(|h| exec(h, "home.hideRows", json!({"rows": "6:7"})))),
|
||||
("chart resize", Box::new(|h| exec(h, "chart.set", json!({"chart": 1, "width": 360, "height": 240})))),
|
||||
("format change", Box::new(|h| exec(h, "home.bold", json!({"range": "A1:B1"})))),
|
||||
("number format", Box::new(|h| exec(h, "home.numberFormat", json!({"range": "B2:B12", "format": "0.00"})))),
|
||||
(
|
||||
"document theme",
|
||||
Box::new(|h| {
|
||||
exec(
|
||||
h,
|
||||
"pageLayout.themeColors",
|
||||
json!({"colors": ["#000000", "#FFFFFF", "#44546A", "#E7E6E6", "#C00000", "#ED7D31", "#A5A5A5", "#FFC000", "#5B9BD5", "#70AD47", "#0563C1", "#954F72"]}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
];
|
||||
for (what, change) in cases {
|
||||
let n = rendered_after(&mut h, |h| change(h));
|
||||
assert_eq!(n, 2, "{what}: both visible charts rendered once");
|
||||
let n = rendered_after(&mut h, |h| h.run_steps(5));
|
||||
assert_eq!(n, 0, "{what}: and then nothing more");
|
||||
}
|
||||
// Zoom is part of the key too.
|
||||
let n = rendered_after(&mut h, |h| exec(h, "view.zoom", json!({"percent": 150})));
|
||||
assert_eq!(n, 2, "zoom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_sheets_and_loading_files_render_the_new_charts_once() {
|
||||
let mut h = harness(two_charts());
|
||||
exec(&mut h, "home.insertSheet", json!({"before": 1}));
|
||||
h.run_steps(4);
|
||||
// The new sheet has no charts; coming back renders this sheet's two once.
|
||||
let n = rendered_after(&mut h, |h| exec(h, "sheet.activate", json!({"sheet": 0})));
|
||||
assert_eq!(n, 2, "sheet switch");
|
||||
let n = rendered_after(&mut h, |h| h.run_steps(5));
|
||||
assert_eq!(n, 0);
|
||||
|
||||
// File load: the same workbook opened from bytes is a new document with its own charts.
|
||||
let bytes = h.state_mut().session.execute("file.saveBytes", json!({})).unwrap();
|
||||
let b64 = bytes["base64"].as_str().unwrap().to_string();
|
||||
let n = rendered_after(&mut h, |h| exec(h, "file.open", json!({"name": "again.xlsx", "base64": b64})));
|
||||
assert_eq!(n, 2, "file load");
|
||||
assert_eq!(h.state().session.documents().len(), 2);
|
||||
let n = rendered_after(&mut h, |h| h.run_steps(5));
|
||||
assert_eq!(n, 0);
|
||||
assert_eq!(h.state().chart_cache.lock().unwrap().docs(), 2, "both open documents are cached");
|
||||
|
||||
// Back in the first document nothing is rendered: its entries were kept.
|
||||
let n = rendered_after(&mut h, |h| h.state_mut().session.set_active(0));
|
||||
assert_eq!(n, 0, "the first document's charts were still cached");
|
||||
// Closing a document forgets its entries; the other one is shown (and rendered) now.
|
||||
let n = rendered_after(&mut h, |h| exec(h, "file.close", json!({"force": true})));
|
||||
assert_eq!(h.state().session.documents().len(), 1);
|
||||
assert_eq!(h.state().chart_cache.lock().unwrap().docs(), 1);
|
||||
assert_eq!(n, 0, "the remaining document's charts were cached too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chart_with_a_volatile_series_is_rendered_on_every_frame() {
|
||||
let mut h = harness(two_charts());
|
||||
{
|
||||
let d = h.state_mut().session.active_mut().unwrap();
|
||||
let sh = Arc::make_mut(&mut d.wb).sheet_mut(0).unwrap();
|
||||
sh.charts[0].series[0].values = "=RAND()".into();
|
||||
}
|
||||
h.run_steps(4);
|
||||
let n = rendered_after(&mut h, |h| h.run_steps(6));
|
||||
assert!(n >= 6, "the volatile chart is resolved again on each frame, got {n}");
|
||||
assert!(n < 12, "the other chart still comes from the cache, got {n}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_documents_are_forgotten_without_painting_a_chart() {
|
||||
let mut h = harness(two_charts());
|
||||
assert_eq!(h.state().chart_cache.lock().unwrap().docs(), 1);
|
||||
exec(&mut h, "file.new", json!({}));
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().session.documents().len(), 2);
|
||||
// The charts' document is no longer shown, but still open.
|
||||
assert_eq!(h.state().chart_cache.lock().unwrap().docs(), 1);
|
||||
h.state_mut().session.set_active(0);
|
||||
h.run_steps(2);
|
||||
exec(&mut h, "file.close", json!({"force": true, "document": true}));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.documents().len(), 1);
|
||||
assert_eq!(h.state().chart_cache.lock().unwrap().docs(), 0, "no chart is painted, yet the closed document's entries are gone");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Floating objects on the sheet: charts (rendered by `gridcraft-chart` into primitives that
|
||||
//! we paint with egui), pictures, shapes, equations, slicers and form controls (`form_controls.rs`).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::epaint::{PathShape, PathStroke};
|
||||
use egui::{Align2, Color32, FontId, Painter, Pos2, Rect, Stroke, StrokeKind, pos2, vec2};
|
||||
use gridcraft_chart::{HAlign, Prim, VAlign};
|
||||
@@ -198,10 +200,11 @@ fn area_mesh(v: &[Pos2], col: Color32) -> egui::Mesh {
|
||||
fn paint_shape_text(p: &Painter, wb: &Workbook, sp: &gridcraft_engine::model::Shape, r: Rect, z: f32) {
|
||||
use gridcraft_engine::model::{ShapeTextAlign, ShapeTextAnchor};
|
||||
let f = &sp.format;
|
||||
let word_art = sp.kind == ShapeKind::WordArt;
|
||||
let text_box = sp.kind == ShapeKind::TextBox;
|
||||
let align = f.align.unwrap_or(if text_box { ShapeTextAlign::Left } else { ShapeTextAlign::Center });
|
||||
let anchor = f.anchor.unwrap_or(if text_box { ShapeTextAnchor::Top } else { ShapeTextAnchor::Middle });
|
||||
let default_color = if text_box { Color32::BLACK } else { Color32::WHITE };
|
||||
let default_color = if text_box || word_art { Color32::BLACK } else { Color32::WHITE };
|
||||
let color = f.text_color.and_then(|c| c.resolve(&wb.theme)).map(theme::color32).unwrap_or(default_color);
|
||||
// Default 14 px (as before); a set size is in points.
|
||||
let size = f.font_size.map_or(14.0, |pt| pt * 96.0 / 72.0).clamp(1.0, 2000.0) * z;
|
||||
@@ -227,6 +230,11 @@ fn paint_shape_text(p: &Painter, wb: &Workbook, sp: &gridcraft_engine::model::Sh
|
||||
};
|
||||
let pos = pos2(x, y);
|
||||
let clip = p.with_clip_rect(r.intersect(p.clip_rect()));
|
||||
// WordArt's drop shadow: the text again, offset down and right, under everything else.
|
||||
if let Some(sc) = f.text_shadow.and_then(|c| c.resolve(&wb.theme)).map(theme::color32) {
|
||||
let d = (size * 0.05).clamp(1.0, 24.0);
|
||||
clip.galley_with_override_text_color(pos + vec2(d, d), g.clone(), sc);
|
||||
}
|
||||
// egui has no stroked text: the outline is the text drawn in the outline colour, offset in
|
||||
// eight directions by the outline width, under the filled text.
|
||||
if let Some((c, w)) = f.text_outline
|
||||
@@ -240,6 +248,31 @@ fn paint_shape_text(p: &Painter, wb: &Workbook, sp: &gridcraft_engine::model::Sh
|
||||
clip.galley(pos, g, color);
|
||||
}
|
||||
|
||||
/// Resolves and renders `ch` (chart `ci` of sheet `si`), at zoom `z`.
|
||||
fn render_chart(p: &Painter, z: f32, wb: &Workbook, si: usize, ch: &gridcraft_engine::model::Chart) -> Vec<Prim> {
|
||||
#[cfg(test)]
|
||||
tests::RENDERS.with(|c| c.set(c.get() + 1));
|
||||
let data = gridcraft_chart::resolve(wb, si, ch);
|
||||
let m = EguiMeasure { painter: p, z };
|
||||
gridcraft_chart::render(ch, &data, ch.anchor.width, ch.anchor.height, &m)
|
||||
}
|
||||
|
||||
/// The primitives of chart `ci` of sheet `si`: from the frame cache when the workbook, zoom and
|
||||
/// screen scale are those it was rendered for, else rendered now (see [`crate::chartcache`]).
|
||||
fn chart_prims(app: &SheetApp, p: &Painter, z: f32, wb: &Arc<Workbook>, si: usize, ci: usize, ch: &gridcraft_engine::model::Chart) -> Arc<[Prim]> {
|
||||
// Only the active document's own workbook is cached: anything else cannot be tied to a uid.
|
||||
let Some(d) = app.session.active().filter(|d| Arc::ptr_eq(&d.wb, wb)) else {
|
||||
return render_chart(p, z, wb, si, ch).into();
|
||||
};
|
||||
// A series that calls NOW or RAND, directly or through a defined name, must be evaluated
|
||||
// again on every paint.
|
||||
if gridcraft_chart::has_volatile_series(wb, si, ch) {
|
||||
return render_chart(p, z, wb, si, ch).into();
|
||||
}
|
||||
let mut cache = app.chart_cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
cache.get(d.uid, wb, si, ci, (z, p.ctx().pixels_per_point()), || render_chart(p, z, wb, si, ch))
|
||||
}
|
||||
|
||||
pub(crate) fn anchor_rect(geo: &Geo, sh: &Sheet, a: &Anchor) -> Rect {
|
||||
let x = geo.x(sh, a.cell.col) + a.dx * geo.z;
|
||||
let y = geo.y(sh, a.cell.row) + a.dy * geo.z;
|
||||
@@ -259,7 +292,7 @@ pub(crate) fn objects_hidden(wb: &Workbook) -> bool {
|
||||
wb.show_objects == gridcraft_engine::model::ShowObjects::None
|
||||
}
|
||||
|
||||
pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet) -> Vec<PaintedObject> {
|
||||
pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Arc<Workbook>, si: usize, sh: &Sheet) -> Vec<PaintedObject> {
|
||||
let mut painted = Vec::new();
|
||||
if objects_hidden(wb) {
|
||||
return painted;
|
||||
@@ -295,6 +328,8 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
|
||||
let line =
|
||||
if sp.format.no_line { Color32::TRANSPARENT } else { sp.line.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::DARK_GRAY) };
|
||||
match sp.kind {
|
||||
// WordArt is only its text (no fill or outline of its own).
|
||||
ShapeKind::WordArt => {}
|
||||
ShapeKind::Rectangle | ShapeKind::TextBox => {
|
||||
p.rect_filled(r, 0.0, fill);
|
||||
p.rect_stroke(r, 0.0, Stroke::new(1.0, line), StrokeKind::Inside);
|
||||
@@ -365,7 +400,7 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
|
||||
}
|
||||
done(start, r);
|
||||
}
|
||||
for ch in &sh.charts {
|
||||
for (ci, ch) in sh.charts.iter().enumerate() {
|
||||
let r = anchor_rect(geo, sh, &ch.anchor);
|
||||
if !r.intersects(p.clip_rect()) {
|
||||
continue;
|
||||
@@ -374,9 +409,7 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
|
||||
// Soft shadow + white card.
|
||||
p.rect_filled(r.translate(vec2(0.0, 1.5)), 2.0, Color32::from_black_alpha(18));
|
||||
p.rect_filled(r, 0.0, Tokens::get(p.ctx()).grid_bg);
|
||||
let data = gridcraft_chart::resolve(wb, si, ch);
|
||||
let m = EguiMeasure { painter: p, z: geo.z };
|
||||
let prims = gridcraft_chart::render(ch, &data, ch.anchor.width, ch.anchor.height, &m);
|
||||
let prims = chart_prims(app, p, geo.z, wb, si, ci, ch);
|
||||
paint_prims(&p.with_clip_rect(r.intersect(p.clip_rect())), r.min, &prims, geo.z);
|
||||
done(start, r);
|
||||
}
|
||||
@@ -583,11 +616,18 @@ pub(crate) fn ink_color(line: Color32, width_pt: f32) -> Color32 {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
pub(crate) mod tests {
|
||||
use std::cell::Cell;
|
||||
|
||||
use gridcraft_chart::Measure;
|
||||
|
||||
use super::*;
|
||||
|
||||
thread_local! {
|
||||
/// Charts resolved and rendered on this thread (a test runs on its own thread).
|
||||
pub(crate) static RENDERS: Cell<usize> = const { Cell::new(0) };
|
||||
}
|
||||
|
||||
/// The production measure is not monotone for labels with a line break: egui lays the text out
|
||||
/// line by line, so ending a prefix with `\n…` can be narrower than the shorter `iii…`. The
|
||||
/// chart's label fitting must not assume otherwise (#1152).
|
||||
|
||||
@@ -112,15 +112,19 @@ pub enum Drag {
|
||||
source: RangeRef,
|
||||
before: Option<u32>,
|
||||
},
|
||||
/// `to`: the previewed size once the pointer moved. Only the dragged line changes while the
|
||||
/// button is held; the release applies the size to every selected line (Excel).
|
||||
ResizeCol {
|
||||
col: u32,
|
||||
start: f32,
|
||||
orig: f32,
|
||||
to: Option<f32>,
|
||||
},
|
||||
ResizeRow {
|
||||
row: u32,
|
||||
start: f32,
|
||||
orig: f32,
|
||||
to: Option<f32>,
|
||||
},
|
||||
/// Dragging the fill handle; `menu` when the right button drags it (the fill menu opens on release).
|
||||
Fill {
|
||||
@@ -244,6 +248,12 @@ pub struct GridState {
|
||||
/// active sheet's rules, kept the same way: they are read from that sheet's cells alone.
|
||||
pub(crate) cf: Option<(std::sync::Weak<Sheet>, CfCache)>,
|
||||
pub drag: Drag,
|
||||
/// A header-boundary resize drag: the workbook as it was when the drag began. The drag's own
|
||||
/// undo entries are the ones from the first entry that snapshots it; earlier edits with the
|
||||
/// same label are not the drag's to merge or cancel.
|
||||
pub resize_base: Option<Arc<Workbook>>,
|
||||
/// The redo stack when that drag began; Esc puts it back (the preview commands clear it).
|
||||
pub resize_redo: Vec<gridcraft_engine::HistoryEntry>,
|
||||
pub ensure_visible: bool,
|
||||
/// With `ensure_visible`: scroll to the moving end of the selection instead of the active cell
|
||||
/// (Shift/Ctrl+Shift+arrows keep the active cell on the anchor while the far corner moves).
|
||||
|
||||
@@ -325,7 +325,8 @@ fn handle_column_header_press(app: &mut SheetApp, input: &InteractionInput<'_>,
|
||||
let in_col_header = input.in_column_header;
|
||||
if in_col_header {
|
||||
if let Some((c, x)) = col_edge(geo, sh, p.x) {
|
||||
app.grid.drag = Drag::ResizeCol { col: c, start: p.x, orig: sh.col_width(c).max(0.0) * 0.0 + (x - geo.x(sh, c)) / geo.z };
|
||||
begin_resize(app);
|
||||
app.grid.drag = Drag::ResizeCol { col: c, start: p.x, orig: sh.col_width(c).max(0.0) * 0.0 + (x - geo.x(sh, c)) / geo.z, to: None };
|
||||
return;
|
||||
}
|
||||
let c = geo.col_at(sh, p.x);
|
||||
@@ -362,7 +363,8 @@ fn handle_row_header_press(app: &mut SheetApp, input: &InteractionInput<'_>, p:
|
||||
let in_row_header = input.in_row_header;
|
||||
if in_row_header {
|
||||
if let Some((r, y)) = row_edge(geo, sh, p.y) {
|
||||
app.grid.drag = Drag::ResizeRow { row: r, start: p.y, orig: (y - geo.y(sh, r)) / geo.z };
|
||||
begin_resize(app);
|
||||
app.grid.drag = Drag::ResizeRow { row: r, start: p.y, orig: (y - geo.y(sh, r)) / geo.z, to: None };
|
||||
return;
|
||||
}
|
||||
let r = geo.row_at(sh, p.y);
|
||||
@@ -631,6 +633,31 @@ fn handle_context_menu_and_keyboard(app: &mut SheetApp, input: &InteractionInput
|
||||
fn handle_dragging(app: &mut SheetApp, input: &InteractionInput<'_>) {
|
||||
let (geo, sh, wb, sel, pos) = (input.geo, input.sheet, input.workbook, &input.selection, input.position);
|
||||
let resp = input.response;
|
||||
// Esc during a header-boundary drag cancels it: undo the preview, ignore the release. egui
|
||||
// aborts the drag on Esc, so `resp.dragged()` is already false in this frame. The key is
|
||||
// spent on the drag, so the grid's Esc doesn't also drop a pending copy or Format Painter.
|
||||
if matches!(app.grid.drag, Drag::ResizeCol { .. } | Drag::ResizeRow { .. })
|
||||
&& input.context.input_mut(|i| {
|
||||
let pressed = i.key_pressed(egui::Key::Escape);
|
||||
if pressed {
|
||||
// With any modifiers, as before: the drag is cancelled and every Esc press spent.
|
||||
i.events.retain(|e| !matches!(e, egui::Event::Key { key: egui::Key::Escape, pressed: true, .. }));
|
||||
}
|
||||
pressed
|
||||
})
|
||||
{
|
||||
// Undo back to where the drag began, but never past it into an earlier edit.
|
||||
let steps = resize_steps(app);
|
||||
if steps > 0 {
|
||||
let _ = app.session.run("edit.undo", json!({"steps": steps}));
|
||||
}
|
||||
// Undo pushes the cancelled preview onto the redo stack; put back what the drag began with.
|
||||
if let Some(d) = app.session.active_mut() {
|
||||
d.redo = std::mem::take(&mut app.grid.resize_redo);
|
||||
}
|
||||
app.grid.drag = Drag::None;
|
||||
app.grid.resize_base = None;
|
||||
}
|
||||
if resp.dragged()
|
||||
&& let Some(p) = pos
|
||||
{
|
||||
@@ -682,35 +709,22 @@ fn handle_dragging(app: &mut SheetApp, input: &InteractionInput<'_>) {
|
||||
app.grid.drag = Drag::MoveCols { source, before };
|
||||
scroll_by(app, vec2(auto.x, 0.0));
|
||||
}
|
||||
Drag::ResizeCol { col, start, orig } => {
|
||||
let w = (orig + (p.x - start) / geo.z).max(0.0);
|
||||
Drag::ResizeCol { col, start, orig, to } => {
|
||||
let w = nan_safe_size(orig + (p.x - start) / geo.z);
|
||||
app.toast =
|
||||
Some((format!("Width: {:.2} ({} pixels)", gridcraft_engine::cmd::format::points_to_chars(w as f64), w.round()), crate::now_ms()));
|
||||
// Inside a selection of whole columns, every selected column takes the new width
|
||||
// (Ctrl-selected areas included); otherwise only the dragged one.
|
||||
let cols = if sel.ranges.iter().any(|r| r.is_full_cols() && col >= r.start.col && col <= r.end.col) {
|
||||
sel.ranges.iter().filter(|r| r.is_full_cols()).map(|r| r.a1()).collect::<Vec<_>>().join(",")
|
||||
} else {
|
||||
RangeRef::cols(col, col).a1()
|
||||
};
|
||||
let _ = app.session.run("home.columnWidth", json!({"cols": cols, "width": w.round()}));
|
||||
coalesce_last_undo(app);
|
||||
// Preview on the dragged column only; the release applies it to the selection. A
|
||||
// rejected size leaves the last shown preview (and `to`) as it was.
|
||||
let ok = app.session.run("home.columnWidth", json!({"cols": RangeRef::cols(col, col).a1(), "width": w.round()})).is_ok();
|
||||
coalesce_resize_undo(app);
|
||||
app.grid.drag = Drag::ResizeCol { col, start, orig, to: if ok { Some(w) } else { to } };
|
||||
}
|
||||
Drag::ResizeRow { row, start, orig } => {
|
||||
let h = (orig + (p.y - start) / geo.z).max(0.0);
|
||||
Drag::ResizeRow { row, start, orig, to } => {
|
||||
let h = nan_safe_size(orig + (p.y - start) / geo.z);
|
||||
app.toast = Some((format!("Height: {:.2} ({} pixels)", h * 0.75, h.round()), crate::now_ms()));
|
||||
let rows = if sel.ranges.iter().any(|r| r.is_full_rows() && row >= r.start.row && row <= r.end.row) {
|
||||
sel.ranges
|
||||
.iter()
|
||||
.filter(|r| r.is_full_rows())
|
||||
.map(|r| format!("{}:{}", r.start.row + 1, r.end.row + 1))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
} else {
|
||||
format!("{}:{}", row + 1, row + 1)
|
||||
};
|
||||
let _ = app.session.run("home.rowHeight", json!({"rows": rows, "height": h.round()}));
|
||||
coalesce_last_undo(app);
|
||||
let ok = app.session.run("home.rowHeight", json!({"rows": format!("{}:{}", row + 1, row + 1), "height": h.round()})).is_ok();
|
||||
coalesce_resize_undo(app);
|
||||
app.grid.drag = Drag::ResizeRow { row, start, orig, to: if ok { Some(h) } else { to } };
|
||||
}
|
||||
Drag::Freeze { cols, .. } => {
|
||||
app.grid.drag = Drag::Freeze { cols, to: Some(freeze_boundary(geo, sh, p, cols)) };
|
||||
@@ -794,6 +808,21 @@ fn handle_release(app: &mut SheetApp, input: &InteractionInput<'_>) {
|
||||
crate::macro_ui::call(app, "GridCraft", "range.move", json!({"range": src.a1(), "to": to.a1()}));
|
||||
}
|
||||
}
|
||||
Drag::ResizeCol { col, to: Some(w), .. } => {
|
||||
// The preview already sized the dragged column; the other selected ones follow now.
|
||||
let cols = resize_targets(&sel.ranges, col, true);
|
||||
if cols != RangeRef::cols(col, col).a1() {
|
||||
let _ = app.session.run("home.columnWidth", json!({"cols": cols, "width": w.round()}));
|
||||
coalesce_resize_undo(app);
|
||||
}
|
||||
}
|
||||
Drag::ResizeRow { row, to: Some(h), .. } => {
|
||||
let rows = resize_targets(&sel.ranges, row, false);
|
||||
if rows != format!("{}:{}", row + 1, row + 1) {
|
||||
let _ = app.session.run("home.rowHeight", json!({"rows": rows, "height": h.round()}));
|
||||
coalesce_resize_undo(app);
|
||||
}
|
||||
}
|
||||
Drag::MoveCols { source, before: Some(before) } => {
|
||||
if let Err(e) = app.run("sheet.moveColumns", json!({"cols": source.a1(), "before": before})) {
|
||||
app.message = Some(("GridCraft".into(), crate::clean_error(&e)));
|
||||
@@ -936,6 +965,29 @@ fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p
|
||||
}
|
||||
}
|
||||
|
||||
/// A drag size in points: NaN or negative becomes 0 (a hidden line).
|
||||
fn nan_safe_size(v: f32) -> f32 {
|
||||
if v.is_nan() { 0.0 } else { v.max(0.0) }
|
||||
}
|
||||
|
||||
/// The lines a header-boundary drag resizes on release: every selected whole line when the
|
||||
/// dragged one is part of the selection (Ctrl-selected areas included), otherwise only it.
|
||||
fn resize_targets(ranges: &[RangeRef], index: u32, cols: bool) -> String {
|
||||
let whole = |r: &RangeRef| if cols { r.is_full_cols() } else { r.is_full_rows() };
|
||||
let span = |r: &RangeRef| if cols { (r.start.col, r.end.col) } else { (r.start.row, r.end.row) };
|
||||
let name = |r: &RangeRef| {
|
||||
let (a, b) = span(r);
|
||||
if cols { RangeRef::cols(a, b).a1() } else { format!("{}:{}", a + 1, b + 1) }
|
||||
};
|
||||
if ranges.iter().any(|r| whole(r) && (span(r).0..=span(r).1).contains(&index)) {
|
||||
ranges.iter().filter(|r| whole(r)).map(name).collect::<Vec<_>>().join(",")
|
||||
} else if cols {
|
||||
RangeRef::cols(index, index).a1()
|
||||
} else {
|
||||
format!("{}:{}", index + 1, index + 1)
|
||||
}
|
||||
}
|
||||
|
||||
fn coalesce_last_undo(app: &mut SheetApp) {
|
||||
// Merge consecutive drag steps into one undo entry.
|
||||
if let Some(d) = app.session.active_mut()
|
||||
@@ -948,6 +1000,29 @@ fn coalesce_last_undo(app: &mut SheetApp) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a header resize drag: remembers the workbook and the redo stack it began with.
|
||||
fn begin_resize(app: &mut SheetApp) {
|
||||
app.grid.resize_base = app.session.active().map(|d| d.wb.clone());
|
||||
app.grid.resize_redo = app.session.active().map(|d| d.redo.clone()).unwrap_or_default();
|
||||
}
|
||||
|
||||
/// Undo entries a header resize drag has pushed so far: those from the first one that snapshots
|
||||
/// the workbook the drag started from (`resize_base`). A failed command pushes none.
|
||||
fn resize_steps(app: &SheetApp) -> usize {
|
||||
let (Some(base), Some(d)) = (&app.grid.resize_base, app.session.active()) else { return 0 };
|
||||
d.undo.iter().position(|e| std::sync::Arc::ptr_eq(&e.wb, base)).map_or(0, |first| d.undo.len() - first)
|
||||
}
|
||||
|
||||
/// Like [`coalesce_last_undo`] for a resize drag: merges only the drag's own entries, so an
|
||||
/// earlier edit with the same label ("Column width") stays a separate undo step.
|
||||
fn coalesce_resize_undo(app: &mut SheetApp) {
|
||||
if resize_steps(app) >= 2
|
||||
&& let Some(d) = app.session.active_mut()
|
||||
{
|
||||
d.undo.pop();
|
||||
}
|
||||
}
|
||||
|
||||
fn scroll_by(app: &mut SheetApp, d: egui::Vec2) {
|
||||
if d != egui::Vec2::ZERO
|
||||
&& let Some(v) = app.view_mut()
|
||||
@@ -1161,6 +1236,28 @@ pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, exte
|
||||
app.grid.ensure_visible = true;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod resize_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resize_targets_follow_the_selection_only_when_the_dragged_line_is_in_it() {
|
||||
let sel = [RangeRef::cols(0, 0), RangeRef::cols(2, 3)];
|
||||
assert_eq!(resize_targets(&sel, 3, true), "A:A,C:D");
|
||||
assert_eq!(resize_targets(&sel, 1, true), "B:B");
|
||||
assert_eq!(resize_targets(&[RangeRef::rows(1, 2)], 2, false), "2:3");
|
||||
assert_eq!(resize_targets(&[RangeRef::rows(1, 2)], 5, false), "6:6");
|
||||
assert_eq!(resize_targets(&[], 0, true), "A:A");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_sizes_are_nan_safe() {
|
||||
assert_eq!(nan_safe_size(f32::NAN), 0.0);
|
||||
assert_eq!(nan_safe_size(-4.0), 0.0);
|
||||
assert_eq!(nan_safe_size(12.5), 12.5);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -103,6 +103,7 @@ pub enum Icon {
|
||||
Comment,
|
||||
Note,
|
||||
TextBox,
|
||||
WordArt,
|
||||
Symbol,
|
||||
/// An equation: a radical sign over an italic x.
|
||||
Equation,
|
||||
@@ -631,6 +632,10 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) {
|
||||
g.rect(2.0, 3.0, 12.0, 10.0, None, Some(c));
|
||||
g.text(8.0, 8.0, "A", 8.0, c, (false, false));
|
||||
}
|
||||
Icon::WordArt => {
|
||||
g.text(9.0, 9.0, "A", 13.0, Color32::from_rgb(0x8E, 0x4F, 0xB5), (true, false));
|
||||
g.text(8.0, 8.0, "A", 13.0, c, (true, false));
|
||||
}
|
||||
Icon::Symbol => g.text(8.0, 8.0, "Ω", 12.0, c, (false, false)),
|
||||
Icon::Equation => {
|
||||
g.line(&[(1.5, 9.0), (3.5, 8.0), (5.5, 13.5), (8.5, 2.5), (14.5, 2.5)], c, 1.2);
|
||||
|
||||
@@ -194,7 +194,7 @@ pub const SEQUENCES: &[(&str, Entry)] = &[
|
||||
("NU", Entry::Finish(Action::Command("insert.symbol"))), // Excel: Symbols; containment(Symbol)
|
||||
("NZS", Entry::Finish(Action::Command("insert.symbol"))), // Excel: Excel: Symbol (modern)
|
||||
("NV", Entry::Finish(Action::Command("insert.pivotTable"))), // Excel: PivotTable
|
||||
("NW", Entry::Finish(Action::Pending("insert.wordArt"))), // pending; Excel: WordArt; WordArt; reserved until the command lands
|
||||
("NW", Entry::Finish(Action::Command("insert.wordArt"))), // Excel: WordArt
|
||||
("NX", Entry::Finish(Action::Command("insert.textBox"))), // Excel: Text Box
|
||||
("NZK", Entry::Continue), // Excel: Sparklines Menu; group level:
|
||||
("NZKSL", Entry::Finish(Action::Dialog("sparkline"))), // Excel: Line Sparkline
|
||||
|
||||
@@ -1336,9 +1336,7 @@
|
||||
"seq": "NW",
|
||||
"kind": "command",
|
||||
"target": "insert.wordArt",
|
||||
"pending": true,
|
||||
"excel": "WordArt",
|
||||
"note": "WordArt; reserved until the command lands"
|
||||
"excel": "WordArt"
|
||||
},
|
||||
{
|
||||
"seq": "NX",
|
||||
|
||||
@@ -18,6 +18,7 @@ pub const UPDATE_APP: craft_update::app::App = craft_update::app::App {
|
||||
pub mod backstage;
|
||||
mod border_preview;
|
||||
mod cell_pictures;
|
||||
mod chartcache;
|
||||
pub mod chartview;
|
||||
pub mod code_editor;
|
||||
pub mod control;
|
||||
@@ -59,6 +60,7 @@ pub mod theme;
|
||||
mod updates_ui;
|
||||
pub mod widgets;
|
||||
mod window_ui;
|
||||
pub mod wordart;
|
||||
|
||||
pub use window_ui::WindowUi;
|
||||
|
||||
@@ -287,6 +289,8 @@ pub struct SheetApp {
|
||||
pub(crate) capture_geometry_from_raw: bool,
|
||||
/// The floating object selected on the sheet (chart, picture, shape, equation or slicer).
|
||||
pub selected_object: Option<gridcraft_engine::model::ObjRef>,
|
||||
/// Rendered charts kept between frames ([`chartcache`]).
|
||||
pub(crate) chart_cache: std::sync::Mutex<chartcache::ChartCache>,
|
||||
/// The system clipboard's text when GridCraft last copied without publishing to it (a
|
||||
/// control-channel or MCP `edit.copy`/`edit.cut`). While the system clipboard still holds this
|
||||
/// text, GridCraft's copy is the newer one and Paste uses it.
|
||||
|
||||
@@ -128,7 +128,9 @@ pub fn open_action(sh: &Sheet, o: ObjRef) -> OpenAction {
|
||||
match o.kind {
|
||||
ObjKind::Chart => OpenAction::FormatChart(o.id),
|
||||
ObjKind::Equation => OpenAction::EditEquation(o.id),
|
||||
ObjKind::Shape if sh.shapes.iter().any(|s| s.id == o.id && s.kind == ShapeKind::TextBox) => OpenAction::EditText(o.id),
|
||||
ObjKind::Shape if sh.shapes.iter().any(|s| s.id == o.id && matches!(s.kind, ShapeKind::TextBox | ShapeKind::WordArt)) => {
|
||||
OpenAction::EditText(o.id)
|
||||
}
|
||||
ObjKind::Control => OpenAction::EditControl(o.id),
|
||||
ObjKind::Shape | ObjKind::Image | ObjKind::Slicer => OpenAction::Select,
|
||||
}
|
||||
|
||||
@@ -208,6 +208,12 @@ pub(super) fn insert(app: &mut SheetApp, ui: &mut Ui) {
|
||||
if __k.clicked() {
|
||||
act(app, "insert.textBox", json!({}));
|
||||
}
|
||||
// Two lines like its neighbours, so the Insert tab stays as wide as before; the translated label is split, the catalog key is one line.
|
||||
let label = app.ui.language.tr("Decorative Text").replacen(' ', "\n", 1);
|
||||
let wa = big_button(ui, Icon::WordArt, &label, "Decorative Text", true);
|
||||
anchor_keytip(app, ui, &wa, &["insert.wordArt"]);
|
||||
// Menu invocation shows the style gallery; the command itself never opens a dialog.
|
||||
egui::Popup::menu(&wa).show(|ui| crate::wordart::gallery(app, ui));
|
||||
let __k = big_button(ui, Icon::PageLayout, "Header &\nFooter", "Header & Footer", false);
|
||||
control_keytip(app, ui, &__k, "dialog:headerFooter");
|
||||
if __k.clicked() {
|
||||
|
||||
@@ -33,7 +33,7 @@ impl SheetApp {
|
||||
}
|
||||
let Some(d) = self.session.active() else { return false };
|
||||
let Some(sh) = d.wb.active() else { return false };
|
||||
let Some(shape) = sh.shapes.iter().find(|s| s.id == id && s.kind == ShapeKind::TextBox) else {
|
||||
let Some(shape) = sh.shapes.iter().find(|s| s.id == id && matches!(s.kind, ShapeKind::TextBox | ShapeKind::WordArt)) else {
|
||||
return false;
|
||||
};
|
||||
// shape.setText refuses edits on a protected sheet; don't open an editor that can't commit.
|
||||
@@ -118,7 +118,7 @@ pub(crate) fn show(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &gridcr
|
||||
app.text_box_editor = Some(ed);
|
||||
return;
|
||||
}
|
||||
let Some(shape) = sh.shapes.iter().find(|s| s.id == ed.id && s.kind == ShapeKind::TextBox) else {
|
||||
let Some(shape) = sh.shapes.iter().find(|s| s.id == ed.id && matches!(s.kind, ShapeKind::TextBox | ShapeKind::WordArt)) else {
|
||||
app.session.mode = Mode::Ready;
|
||||
return;
|
||||
};
|
||||
@@ -153,7 +153,15 @@ pub(crate) fn show(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &gridcr
|
||||
.add(
|
||||
TextEdit::multiline(&mut ed.text)
|
||||
.id(id)
|
||||
.font(FontId::new(14.0 * geo.z, egui::FontFamily::Name(theme::CELL.into())))
|
||||
.font(FontId::new(
|
||||
// WordArt is edited at its own size (clamped so the box stays usable).
|
||||
if shape.kind == ShapeKind::WordArt {
|
||||
shape.format.font_size.map_or(14.0, |pt| (pt * 96.0 / 72.0).clamp(8.0, 200.0))
|
||||
} else {
|
||||
14.0
|
||||
} * geo.z,
|
||||
egui::FontFamily::Name(theme::CELL.into()),
|
||||
))
|
||||
.text_color(Color32::BLACK)
|
||||
.frame(egui::Frame::NONE)
|
||||
.margin(egui::Margin::ZERO)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Insert › Text › WordArt: the style gallery. Previews are drawn in code from the engine's
|
||||
//! preset palette (`gridcraft_engine::cmd::wordart::STYLES`); picking one runs `insert.wordArt`.
|
||||
|
||||
use egui::{Align2, Color32, FontId, Painter, Rect, Sense, Ui, vec2};
|
||||
use gridcraft_engine::cmd::wordart::{STYLES, WordArtStyle};
|
||||
use gridcraft_engine::model::Theme;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{SheetApp, theme};
|
||||
|
||||
/// Paints "Abc" in `style` centred in `r` (fill, outline and shadow as the sheet object has them).
|
||||
pub(crate) fn paint_sample(p: &Painter, r: Rect, style: &WordArtStyle, theme: &Theme) {
|
||||
let px = 24.0;
|
||||
let family = theme::cell_family("", style.bold, style.italic);
|
||||
let font = FontId::new(px, family);
|
||||
let c = r.center();
|
||||
let col = |c: gridcraft_engine::model::Color| c.resolve(theme).map(theme::color32).unwrap_or(Color32::BLACK);
|
||||
if let Some(sh) = style.shadow {
|
||||
p.text(c + vec2(1.5, 1.5), Align2::CENTER_CENTER, "Abc", font.clone(), col(sh));
|
||||
}
|
||||
if let Some((oc, w)) = style.outline {
|
||||
let d = (w * 96.0 / 72.0 / 2.0).clamp(0.5, 3.0);
|
||||
for (dx, dy) in [(-1.0, -1.0), (0.0, -1.0), (1.0, -1.0), (-1.0, 0.0), (1.0, 0.0), (-1.0, 1.0), (0.0, 1.0), (1.0, 1.0)] {
|
||||
p.text(c + vec2(dx * d, dy * d), Align2::CENTER_CENTER, "Abc", font.clone(), col(oc));
|
||||
}
|
||||
}
|
||||
p.text(c, Align2::CENTER_CENTER, "Abc", font, col(style.fill));
|
||||
}
|
||||
|
||||
/// The gallery popup content: two rows of previews; a click inserts that style.
|
||||
pub(crate) fn gallery(app: &mut SheetApp, ui: &mut Ui) {
|
||||
let theme = app.session.active().map(|d| d.wb.theme.clone()).unwrap_or_default();
|
||||
ui.set_width(5.0 * 72.0);
|
||||
egui::Grid::new("wordart_gallery").spacing(vec2(4.0, 4.0)).show(ui, |ui| {
|
||||
for (i, style) in STYLES.iter().enumerate() {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(68.0, 44.0), Sense::click());
|
||||
let t = theme::Tokens::get(ui.ctx());
|
||||
ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.hover } else { Color32::WHITE });
|
||||
ui.painter().rect_stroke(r, 4.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
|
||||
paint_sample(&ui.painter().with_clip_rect(r), r, style, &theme);
|
||||
if resp.on_hover_text(style.name).clicked() {
|
||||
app.run_or_alert("insert.wordArt", json!({"style": style.name}));
|
||||
ui.close();
|
||||
}
|
||||
if (i + 1) % 5 == 0 {
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -153,6 +153,110 @@ fn dragging_a_boundary_inside_a_multi_area_selection_resizes_every_selected_line
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_multi_selection_resize_previews_on_the_dragged_line_and_applies_on_release() {
|
||||
// Issue #1082: only the dragged line moves while the button is held; Esc cancels.
|
||||
for rows in [false, true] {
|
||||
let mut h = harness(if rows { "1:3" } else { "A:C" });
|
||||
let before = sizes(&h, rows);
|
||||
let (start, zoom) = edge(&h, rows, 1);
|
||||
let delta = if rows { vec2(0.0, 30.0) } else { vec2(30.0, 0.0) };
|
||||
button(&mut h, start, true);
|
||||
h.input_mut().events.push(Event::PointerMoved(start + delta / 2.0));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerMoved(start + delta));
|
||||
h.step();
|
||||
let held = sizes(&h, rows);
|
||||
assert_eq!(held[1], (before[1] + 30.0 / zoom).round(), "the dragged line previews");
|
||||
for index in [0, 2, 3] {
|
||||
assert_eq!(held[index], before[index], "line {index} waits for the release");
|
||||
}
|
||||
button(&mut h, start + delta, false);
|
||||
h.run_steps(2);
|
||||
let after = sizes(&h, rows);
|
||||
for (index, size) in after.iter().take(3).enumerate() {
|
||||
assert_eq!(*size, held[1], "line {index} takes the width on release");
|
||||
}
|
||||
assert_eq!(after[3], before[3]);
|
||||
assert_eq!(h.state().session.active().unwrap().undo.len(), 1);
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(sizes(&h, rows), before);
|
||||
|
||||
// Esc during the drag cancels everything.
|
||||
let mut h = harness(if rows { "1:3" } else { "A:C" });
|
||||
button(&mut h, start, true);
|
||||
h.input_mut().events.push(Event::PointerMoved(start + delta));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::Key {
|
||||
key: egui::Key::Escape,
|
||||
physical_key: None,
|
||||
pressed: true,
|
||||
repeat: false,
|
||||
modifiers: Modifiers::NONE,
|
||||
});
|
||||
h.step();
|
||||
button(&mut h, start + delta, false);
|
||||
h.run_steps(2);
|
||||
assert_eq!(sizes(&h, rows), before, "Esc restores the sizes");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaping_a_resize_drag_keeps_an_earlier_size_edit() {
|
||||
// A resize drag right after another resize shares its undo label; Esc must cancel only the drag.
|
||||
for (rows, selection) in [(false, "B:B"), (true, "2:2"), (false, "A:C"), (true, "1:3")] {
|
||||
let mut h = harness(selection);
|
||||
let key = if rows { ("home.rowHeight", "rows", "2:2", "height") } else { ("home.columnWidth", "cols", "B:B", "width") };
|
||||
h.state_mut().session.execute(key.0, json!({key.1: key.2, key.3: 80})).unwrap();
|
||||
h.run_steps(2);
|
||||
let earlier = sizes(&h, rows);
|
||||
let undo_len = h.state().session.active().unwrap().undo.len();
|
||||
let (start, _) = edge(&h, rows, 1);
|
||||
let delta = if rows { vec2(0.0, 30.0) } else { vec2(30.0, 0.0) };
|
||||
button(&mut h, start, true);
|
||||
h.input_mut().events.push(Event::PointerMoved(start + delta / 2.0));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerMoved(start + delta));
|
||||
h.step();
|
||||
assert_ne!(sizes(&h, rows), earlier, "the drag previews");
|
||||
h.input_mut().events.push(Event::Key {
|
||||
key: egui::Key::Escape,
|
||||
physical_key: None,
|
||||
pressed: true,
|
||||
repeat: false,
|
||||
modifiers: Modifiers::NONE,
|
||||
});
|
||||
h.step();
|
||||
button(&mut h, start + delta, false);
|
||||
h.run_steps(2);
|
||||
assert_eq!(sizes(&h, rows), earlier, "Esc cancels only the drag (rows: {rows}, selection {selection})");
|
||||
assert_eq!(h.state().session.active().unwrap().undo.len(), undo_len, "the earlier edit stays on the undo stack");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resize_drag_after_another_resize_is_its_own_undo_step() {
|
||||
for (rows, selection) in [(false, "B:B"), (true, "2:2"), (false, "A:C"), (true, "1:3")] {
|
||||
let mut h = harness(selection);
|
||||
let key = if rows { ("home.rowHeight", "rows", "2:2", "height") } else { ("home.columnWidth", "cols", "B:B", "width") };
|
||||
h.state_mut().session.execute(key.0, json!({key.1: key.2, key.3: 80})).unwrap();
|
||||
h.run_steps(2);
|
||||
let earlier = sizes(&h, rows);
|
||||
let (start, _) = edge(&h, rows, 1);
|
||||
let delta = if rows { vec2(0.0, 30.0) } else { vec2(30.0, 0.0) };
|
||||
button(&mut h, start, true);
|
||||
h.input_mut().events.push(Event::PointerMoved(start + delta / 2.0));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerMoved(start + delta));
|
||||
h.step();
|
||||
button(&mut h, start + delta, false);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().session.active().unwrap().undo.len(), 2, "selection {selection}: two undo steps");
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(sizes(&h, rows), earlier, "selection {selection}: one undo reverts only the drag");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_filter_autofit_works_through_command_and_header_double_click() {
|
||||
for zoom in [50, 100, 200] {
|
||||
@@ -191,3 +295,123 @@ fn table_filter_autofit_works_through_command_and_header_double_click() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn press_escape(h: &mut egui_kittest::Harness<'_, SheetApp>) {
|
||||
h.input_mut().events.push(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rejected_final_preview_releases_with_the_size_last_shown() {
|
||||
// The engine refuses rows over 546 px and columns over 1800 px; the release must apply the last
|
||||
// accepted preview to the whole selection, not retry the refused size.
|
||||
for rows in [false, true] {
|
||||
let mut h = harness(if rows { "1:3" } else { "A:C" });
|
||||
let before = sizes(&h, rows);
|
||||
let (start, zoom) = edge(&h, rows, 1);
|
||||
let unit = |d: f32| if rows { vec2(0.0, d) } else { vec2(d, 0.0) };
|
||||
button(&mut h, start, true);
|
||||
h.input_mut().events.push(Event::PointerMoved(start + unit(15.0)));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerMoved(start + unit(30.0)));
|
||||
h.step();
|
||||
let shown = sizes(&h, rows)[1];
|
||||
assert_eq!(shown, (before[1] + 30.0 / zoom).round());
|
||||
h.input_mut().events.push(Event::PointerMoved(start + unit(4000.0 * zoom)));
|
||||
h.step();
|
||||
assert_eq!(sizes(&h, rows)[1], shown, "the refused size is not shown");
|
||||
button(&mut h, start + unit(4000.0 * zoom), false);
|
||||
h.run_steps(2);
|
||||
let after = sizes(&h, rows);
|
||||
for (index, size) in after.iter().take(3).enumerate() {
|
||||
assert_eq!(*size, shown, "line {index} takes the size that was shown (rows: {rows})");
|
||||
}
|
||||
assert_eq!(after[3], before[3]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaping_a_resize_drag_leaves_redo_history_as_it_was() {
|
||||
for rows in [false, true] {
|
||||
for with_redo in [false, true] {
|
||||
let mut h = harness(if rows { "1:3" } else { "A:C" });
|
||||
let cmd = if rows { ("home.rowHeight", "rows", "4:4", "height") } else { ("home.columnWidth", "cols", "D:D", "width") };
|
||||
let mut redo_len = 0;
|
||||
let mut d_size = 0.0;
|
||||
if with_redo {
|
||||
h.state_mut().session.execute(cmd.0, json!({cmd.1: cmd.2, cmd.3: 80})).unwrap();
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
redo_len = h.state().session.active().unwrap().redo.len();
|
||||
assert_eq!(redo_len, 1);
|
||||
d_size = sizes(&h, rows)[3];
|
||||
}
|
||||
let before = sizes(&h, rows);
|
||||
let (start, _) = edge(&h, rows, 1);
|
||||
let delta = if rows { vec2(0.0, 30.0) } else { vec2(30.0, 0.0) };
|
||||
button(&mut h, start, true);
|
||||
h.input_mut().events.push(Event::PointerMoved(start + delta / 2.0));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerMoved(start + delta));
|
||||
h.step();
|
||||
assert_ne!(sizes(&h, rows), before, "the drag previews");
|
||||
press_escape(&mut h);
|
||||
button(&mut h, start + delta, false);
|
||||
h.run_steps(2);
|
||||
assert_eq!(sizes(&h, rows), before);
|
||||
assert_eq!(h.state().session.active().unwrap().redo.len(), redo_len, "Esc leaves the redo stack alone (rows: {rows}, redo: {with_redo})");
|
||||
// Ctrl+Y brings back only what was undone before the drag.
|
||||
let redone = h.state_mut().session.execute("edit.redo", json!({}));
|
||||
if with_redo {
|
||||
redone.unwrap();
|
||||
let redone = sizes(&h, rows);
|
||||
assert_eq!(redone[3], 80.0);
|
||||
assert_ne!(redone[3], d_size);
|
||||
assert_eq!(redone[..3], before[..3], "the cancelled drag does not come back");
|
||||
} else {
|
||||
assert!(redone.is_err(), "nothing to redo after a cancelled drag");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaping_a_resize_drag_keeps_a_pending_copy_and_format_painter() {
|
||||
// Esc cancels the drag only: the marked copy and Format Painter stay, whether the release comes
|
||||
// in a later frame or in the same frame as Esc.
|
||||
for rows in [false, true] {
|
||||
for (same_frame, modifiers) in [(false, Modifiers::NONE), (true, Modifiers::NONE), (false, Modifiers::SHIFT)] {
|
||||
let mut h = harness(if rows { "1:3" } else { "A:C" });
|
||||
h.state_mut().session.execute("edit.copy", json!({"range": "A1:B2"})).unwrap();
|
||||
h.state_mut().session.execute("edit.formatPainter", json!({"sticky": true})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().session.clipboard.is_some() && h.state().session.format_painter.is_some());
|
||||
let before = sizes(&h, rows);
|
||||
let (start, _) = edge(&h, rows, 1);
|
||||
let delta = if rows { vec2(0.0, 30.0) } else { vec2(30.0, 0.0) };
|
||||
button(&mut h, start, true);
|
||||
h.input_mut().events.push(Event::PointerMoved(start + delta / 2.0));
|
||||
h.step();
|
||||
h.input_mut().events.push(Event::PointerMoved(start + delta));
|
||||
h.step();
|
||||
assert_ne!(sizes(&h, rows), before, "the drag previews");
|
||||
let pos = start + delta;
|
||||
let esc = Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers };
|
||||
if same_frame {
|
||||
h.input_mut().events.extend([esc, Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers }]);
|
||||
} else {
|
||||
h.input_mut().events.push(esc);
|
||||
h.step();
|
||||
h.input_mut()
|
||||
.events
|
||||
.extend([Event::PointerMoved(pos), Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers }]);
|
||||
}
|
||||
h.step();
|
||||
h.run_steps(2);
|
||||
let what = format!("rows: {rows}, same frame: {same_frame}, modifiers: {modifiers:?}");
|
||||
assert_eq!(sizes(&h, rows), before, "the drag is cancelled ({what})");
|
||||
assert!(h.state().session.clipboard.is_some(), "the copy is still marked ({what})");
|
||||
assert!(h.state().session.format_painter.is_some(), "Format Painter is still on ({what})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1396,6 +1396,28 @@ fn macro_workbook_bytes() -> Vec<u8> {
|
||||
gridcraft_engine::io::save_bytes(&wb, "macros.xlsm").unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circle_invalid_data_warns_when_its_scan_was_cut_short() {
|
||||
let mut h = harness(blank());
|
||||
h.state_mut().run("range.setValues", json!({"range": "A1", "values": [[-1]]})).unwrap();
|
||||
h.state_mut().run("range.setValues", json!({"range": "A200000", "values": [[-1]]})).unwrap();
|
||||
h.state_mut().run("data.validation", json!({"range": "A1:A200000", "type": "whole", "operator": "greater", "formula1": "0"})).unwrap();
|
||||
h.state_mut().run("data.circleInvalid", json!({})).unwrap();
|
||||
let n = h.state().notice.clone().expect("a notice was shown");
|
||||
assert!(n.text.contains("100,000 cell visits"), "{}", n.text);
|
||||
// An open-time warning keeps its action when the scan note joins it.
|
||||
h.state_mut().notice = Some(gridcraft_ui_egui::Notice {
|
||||
text: "Macros.".into(),
|
||||
kind: gridcraft_ui_egui::NoticeKind::Info,
|
||||
action: Some(("More".into(), "macros".into())),
|
||||
});
|
||||
h.state_mut().run("data.circleInvalid", json!({})).unwrap();
|
||||
let n = h.state().notice.clone().unwrap();
|
||||
assert!(n.text.starts_with("Macros. ") && n.text.contains("cell visits"), "{}", n.text);
|
||||
assert_eq!(n.action.as_ref().map(|a| a.1.as_str()), Some("macros"));
|
||||
assert_eq!(n.kind, gridcraft_ui_egui::NoticeKind::Warning);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_a_macro_workbook_shows_a_notice() {
|
||||
// The reader's "macros kept but not executed" warning must reach a dismissible info bar,
|
||||
|
||||
@@ -1417,10 +1417,7 @@ fn keytip_nv_() {
|
||||
fn keytip_nw_() {
|
||||
let mut h = harness(blank());
|
||||
sequence(&mut h, "NW", false);
|
||||
assert!(h.state().dialog.is_none(), "NW must not open a dialog");
|
||||
assert!(h.state().message.is_none(), "NW must not open a message box");
|
||||
assert!(h.state().toast.is_none(), "NW must not raise a toast");
|
||||
assert!(h.state().session.journal.is_empty(), "NW must not journal");
|
||||
assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.wordArt"), "NW must journal insert.wordArt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1064,8 +1064,8 @@ fn the_windows_cancel_with_an_unsavable_draft_closes_the_dialog_and_says_why() {
|
||||
assert_eq!(draft_text(&h).as_deref(), Some("Picked note"), "the draft is kept");
|
||||
assert_eq!(selection(&h).0, 2, "the selection stays where it is");
|
||||
let wb = &h.state().session.active().unwrap().wb;
|
||||
for sheet in 0..3 {
|
||||
assert!(wb.sheets[sheet].value(CellRef::parse("H1").unwrap()).is_empty(), "nothing ran on sheet {sheet}");
|
||||
for (index, sheet) in wb.sheets[..3].iter().enumerate() {
|
||||
assert!(sheet.value(CellRef::parse("H1").unwrap()).is_empty(), "nothing ran on sheet {index}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -495,7 +495,7 @@ fn vertical_alignment_keytips_run_their_commands() {
|
||||
fn pending_sequences_are_silent_no_ops() {
|
||||
let mut h = harness(blank());
|
||||
let journal = h.state().session.journal.len();
|
||||
sequence(&mut h, &[Key::N, Key::W], false); // NW -> insert.wordArt (missing)
|
||||
sequence(&mut h, &[Key::N, Key::S, Key::C], false); // NSC -> insert.screenshot (missing)
|
||||
assert!(h.state().dialog.is_none(), "no dialog for a pending sequence");
|
||||
assert!(h.state().message.is_none(), "no message box for a pending sequence");
|
||||
assert!(h.state().toast.is_none(), "no toast for a pending sequence");
|
||||
|
||||
@@ -18,11 +18,14 @@ gridcraft-core = { workspace = true }
|
||||
gridcraft-formula = { workspace = true }
|
||||
gridcraft-math = { workspace = true }
|
||||
gridcraft-model = { workspace = true }
|
||||
craft-cfb = { workspace = true }
|
||||
craft-csv = { workspace = true }
|
||||
craft-offcrypto = { workspace = true }
|
||||
craft-opc = { workspace = true }
|
||||
craft-xls = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
quick-xml = { workspace = true }
|
||||
zip = { workspace = true }
|
||||
aes = { workspace = true }
|
||||
sha1 = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -1,455 +0,0 @@
|
||||
//! Just enough of the Compound File Binary (CFB/OLE2) container to tell what a non-zip
|
||||
//! spreadsheet file is: Excel wraps password-protected workbooks in a CFB holding an
|
||||
//! `EncryptedPackage` stream, while Excel 97-2003 `.xls` (and other legacy Office files) are
|
||||
//! CFB files without one. Every read is bounds-checked and every loop bounded.
|
||||
|
||||
/// The CFB signature at the start of the file.
|
||||
pub(crate) const SIGNATURE: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1];
|
||||
|
||||
const END_OF_CHAIN: u32 = 0xFFFF_FFFE;
|
||||
/// Sector ids at or above this are markers (free, end of chain, FAT/DIFAT sector), not sectors.
|
||||
const MAX_REG_SECT: u32 = 0xFFFF_FFFA;
|
||||
/// Directory sectors followed at most (4096 entries with 512-byte sectors).
|
||||
const MAX_DIR_SECTORS: usize = 1024;
|
||||
/// DIFAT sectors followed at most.
|
||||
const MAX_DIFAT_SECTORS: usize = 1024;
|
||||
|
||||
/// Stream names that mark an encrypted (password-protected) OOXML package.
|
||||
const ENCRYPTED_STREAMS: [&str; 2] = ["EncryptedPackage", "EncryptionInfo"];
|
||||
|
||||
fn u16_at(b: &[u8], at: usize) -> Option<u16> {
|
||||
let s = b.get(at..at.checked_add(2)?)?;
|
||||
Some(u16::from_le_bytes([*s.first()?, *s.get(1)?]))
|
||||
}
|
||||
|
||||
fn u32_at(b: &[u8], at: usize) -> Option<u32> {
|
||||
let s = b.get(at..at.checked_add(4)?)?;
|
||||
Some(u32::from_le_bytes([*s.first()?, *s.get(1)?, *s.get(2)?, *s.get(3)?]))
|
||||
}
|
||||
|
||||
/// Whether the compound file holds an encryption stream. Walks the directory through the FAT;
|
||||
/// when the structure can't be followed (truncated or malformed), falls back to scanning the
|
||||
/// bytes for the UTF-16LE stream names.
|
||||
pub(crate) fn is_encrypted(bytes: &[u8]) -> bool {
|
||||
match directory_names(bytes) {
|
||||
Some(names) => names.iter().any(|n| ENCRYPTED_STREAMS.iter().any(|e| n.eq_ignore_ascii_case(e))),
|
||||
None => ENCRYPTED_STREAMS.iter().any(|e| contains_utf16(bytes, e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn contains_utf16(hay: &[u8], needle: &str) -> bool {
|
||||
let n: Vec<u8> = needle.encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||
!n.is_empty() && hay.windows(n.len()).any(|w| w == n.as_slice())
|
||||
}
|
||||
|
||||
/// Names of every directory entry, or `None` when the directory can't be read.
|
||||
fn directory_names(b: &[u8]) -> Option<Vec<String>> {
|
||||
if !b.starts_with(&SIGNATURE) {
|
||||
return None;
|
||||
}
|
||||
let shift = u16_at(b, 30)?;
|
||||
if shift != 9 && shift != 12 {
|
||||
return None;
|
||||
}
|
||||
let size = 1usize << shift;
|
||||
let per = size / 4;
|
||||
let sector = |id: u32| -> Option<&[u8]> {
|
||||
let start = (id as usize).checked_add(1)?.checked_mul(size)?;
|
||||
b.get(start..start.checked_add(size)?)
|
||||
};
|
||||
// The DIFAT: the FAT's sector ids, 109 in the header then chained DIFAT sectors.
|
||||
let mut difat: Vec<u32> = (0..109).filter_map(|i| u32_at(b, 76 + i * 4)).filter(|&s| s < MAX_REG_SECT).collect();
|
||||
let mut next = u32_at(b, 68)?;
|
||||
for _ in 0..MAX_DIFAT_SECTORS {
|
||||
if next >= MAX_REG_SECT {
|
||||
break;
|
||||
}
|
||||
let s = sector(next)?;
|
||||
difat.extend((0..per - 1).filter_map(|i| u32_at(s, i * 4)).filter(|&x| x < MAX_REG_SECT));
|
||||
next = u32_at(s, (per - 1) * 4)?;
|
||||
}
|
||||
let fat_next = |id: u32| -> Option<u32> {
|
||||
let idx = id as usize;
|
||||
let fat_sector = *difat.get(idx / per)?;
|
||||
u32_at(sector(fat_sector)?, (idx % per) * 4)
|
||||
};
|
||||
let mut names = Vec::new();
|
||||
let mut id = u32_at(b, 48)?;
|
||||
for _ in 0..MAX_DIR_SECTORS {
|
||||
if id == END_OF_CHAIN {
|
||||
return Some(names);
|
||||
}
|
||||
if id >= MAX_REG_SECT {
|
||||
return None;
|
||||
}
|
||||
let s = sector(id)?;
|
||||
for e in s.as_chunks::<128>().0 {
|
||||
let len = usize::from(u16_at(e, 64)?);
|
||||
if !(2..=64).contains(&len) {
|
||||
continue;
|
||||
}
|
||||
let units: Vec<u16> = (0..(len - 2) / 2).filter_map(|i| u16_at(e, i * 2)).collect();
|
||||
names.push(String::from_utf16_lossy(&units));
|
||||
}
|
||||
id = fat_next(id)?;
|
||||
}
|
||||
// A directory longer than the bound (or a cyclic chain): report what was read.
|
||||
Some(names)
|
||||
}
|
||||
|
||||
/// Directory entry type of a stream object ([MS-CFB] 2.6.1).
|
||||
const STREAM_OBJECT: u8 = 2;
|
||||
/// "No entry" in the directory tree (NOSTREAM).
|
||||
const NO_STREAM: u32 = 0xFFFF_FFFF;
|
||||
/// Streams larger than this are refused (the format allows more, a workbook never needs it).
|
||||
const MAX_STREAM: u64 = 1 << 30;
|
||||
|
||||
/// One directory entry ([MS-CFB] 2.6).
|
||||
struct DirEntry {
|
||||
name: String,
|
||||
kind: u8,
|
||||
left: u32,
|
||||
right: u32,
|
||||
child: u32,
|
||||
start: u32,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
/// A parsed compound file: the FAT, the mini FAT and the directory, all bounds-checked.
|
||||
/// Implemented from Microsoft's public [MS-CFB] specification.
|
||||
pub(crate) struct Compound<'a> {
|
||||
bytes: &'a [u8],
|
||||
sector_size: usize,
|
||||
mini_sector_size: usize,
|
||||
mini_cutoff: u64,
|
||||
fat: Vec<u32>,
|
||||
mini_fat: Vec<u32>,
|
||||
entries: Vec<DirEntry>,
|
||||
}
|
||||
|
||||
impl<'a> Compound<'a> {
|
||||
/// Parses the header, FAT, mini FAT and directory. Errors on anything that can't be followed.
|
||||
pub(crate) fn open(b: &'a [u8]) -> Result<Compound<'a>, String> {
|
||||
let bad = |m: &str| format!("damaged compound file: {m}");
|
||||
if !b.starts_with(&SIGNATURE) {
|
||||
return Err(bad("missing signature"));
|
||||
}
|
||||
let major = u16_at(b, 26).ok_or_else(|| bad("truncated header"))?;
|
||||
let shift = u16_at(b, 30).ok_or_else(|| bad("truncated header"))?;
|
||||
if shift != 9 && shift != 12 {
|
||||
return Err(bad("unsupported sector size"));
|
||||
}
|
||||
let mini_shift = u16_at(b, 32).ok_or_else(|| bad("truncated header"))?;
|
||||
if mini_shift != 6 {
|
||||
return Err(bad("unsupported mini sector size"));
|
||||
}
|
||||
let sector_size = 1usize << shift;
|
||||
let per = sector_size / 4;
|
||||
// Sectors that physically exist after the header sector: every chain is bounded by it.
|
||||
let sectors = (b.len() / sector_size).saturating_sub(1);
|
||||
let sector = |id: u32| -> Option<&[u8]> {
|
||||
let start = (id as usize).checked_add(1)?.checked_mul(sector_size)?;
|
||||
b.get(start..start.checked_add(sector_size)?)
|
||||
};
|
||||
// DIFAT: 109 FAT sector ids in the header, then chained DIFAT sectors.
|
||||
let mut fat_sectors: Vec<u32> = (0..109).filter_map(|i| u32_at(b, 76 + i * 4)).filter(|&s| s < MAX_REG_SECT).collect();
|
||||
let mut next = u32_at(b, 68).ok_or_else(|| bad("truncated header"))?;
|
||||
let mut hops = 0usize;
|
||||
while next < MAX_REG_SECT {
|
||||
hops += 1;
|
||||
if hops > sectors.min(MAX_DIFAT_SECTORS * 64) {
|
||||
return Err(bad("DIFAT chain loops"));
|
||||
}
|
||||
let s = sector(next).ok_or_else(|| bad("DIFAT sector out of range"))?;
|
||||
fat_sectors.extend((0..per - 1).filter_map(|i| u32_at(s, i * 4)).filter(|&x| x < MAX_REG_SECT));
|
||||
next = u32_at(s, (per - 1) * 4).ok_or_else(|| bad("truncated DIFAT sector"))?;
|
||||
}
|
||||
if fat_sectors.len() > sectors {
|
||||
return Err(bad("more FAT sectors than the file holds"));
|
||||
}
|
||||
let mut fat = Vec::with_capacity(fat_sectors.len().saturating_mul(per));
|
||||
for id in fat_sectors {
|
||||
let s = sector(id).ok_or_else(|| bad("FAT sector out of range"))?;
|
||||
fat.extend((0..per).filter_map(|i| u32_at(s, i * 4)));
|
||||
}
|
||||
let mut cf = Compound { bytes: b, sector_size, mini_sector_size: 64, mini_cutoff: 4096, fat, mini_fat: vec![], entries: vec![] };
|
||||
if let Some(c) = u32_at(b, 56) {
|
||||
cf.mini_cutoff = u64::from(c);
|
||||
}
|
||||
// Directory.
|
||||
let dir_start = u32_at(b, 48).ok_or_else(|| bad("truncated header"))?;
|
||||
let dir = cf.read_chain(dir_start, u64::MAX)?;
|
||||
for e in dir.as_chunks::<128>().0 {
|
||||
let len = usize::from(u16_at(e, 64).unwrap_or(0)).min(64);
|
||||
let units: Vec<u16> = (0..len.saturating_sub(2) / 2).filter_map(|i| u16_at(e, i * 2)).collect();
|
||||
let mut size = u32_at(e, 120).map(u64::from).unwrap_or(0);
|
||||
if major >= 4 {
|
||||
size |= u64::from(u32_at(e, 124).unwrap_or(0)) << 32;
|
||||
}
|
||||
cf.entries.push(DirEntry {
|
||||
name: String::from_utf16_lossy(&units),
|
||||
kind: e.get(66).copied().unwrap_or(0),
|
||||
left: u32_at(e, 68).unwrap_or(NO_STREAM),
|
||||
right: u32_at(e, 72).unwrap_or(NO_STREAM),
|
||||
child: u32_at(e, 76).unwrap_or(NO_STREAM),
|
||||
start: u32_at(e, 116).unwrap_or(END_OF_CHAIN),
|
||||
size,
|
||||
});
|
||||
}
|
||||
if cf.entries.first().is_none_or(|r| r.kind != 5) {
|
||||
return Err(bad("missing root entry"));
|
||||
}
|
||||
// Mini FAT.
|
||||
let mini_start = u32_at(b, 60).ok_or_else(|| bad("truncated header"))?;
|
||||
if mini_start < MAX_REG_SECT {
|
||||
let raw = cf.read_chain(mini_start, u64::MAX)?;
|
||||
cf.mini_fat = (0..raw.len() / 4).filter_map(|i| u32_at(&raw, i * 4)).collect();
|
||||
}
|
||||
Ok(cf)
|
||||
}
|
||||
|
||||
/// Follows a FAT chain from `start`, reading at most `limit` bytes. A chain longer than the
|
||||
/// FAT (so a cycle) or pointing outside the file is an error. The file may end inside the
|
||||
/// chain's last sector (some writers don't pad it, and Excel opens those files): that sector
|
||||
/// reads short, and the caller checks the stream's declared size against what was read.
|
||||
fn read_chain(&self, start: u32, limit: u64) -> Result<Vec<u8>, String> {
|
||||
let limit = usize::try_from(limit.min(MAX_STREAM)).unwrap_or(usize::MAX);
|
||||
let mut out = Vec::new();
|
||||
let mut id = start;
|
||||
let mut seen = vec![false; self.fat.len()];
|
||||
let mut cut = false;
|
||||
while id != END_OF_CHAIN && out.len() < limit {
|
||||
if cut {
|
||||
return Err("damaged compound file: sector out of range (the file may be truncated)".into());
|
||||
}
|
||||
if id >= MAX_REG_SECT {
|
||||
return Err("damaged compound file: broken sector chain".into());
|
||||
}
|
||||
match seen.get_mut(id as usize) {
|
||||
Some(v) if !*v => *v = true,
|
||||
Some(_) => return Err("damaged compound file: sector chain loops".into()),
|
||||
None => return Err("damaged compound file: sector missing from the FAT".into()),
|
||||
}
|
||||
let start = (id as usize).checked_add(1).and_then(|i| i.checked_mul(self.sector_size));
|
||||
let s = start
|
||||
.and_then(|s| self.bytes.get(s..s.checked_add(self.sector_size)?.min(self.bytes.len())))
|
||||
.ok_or("damaged compound file: sector out of range (the file may be truncated)")?;
|
||||
cut = s.len() < self.sector_size;
|
||||
out.extend_from_slice(s);
|
||||
id = *self.fat.get(id as usize).ok_or("damaged compound file: sector missing from the FAT")?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Follows a mini FAT chain inside the mini stream container. Like `read_chain`, the
|
||||
/// container may end inside the chain's last mini sector.
|
||||
fn read_mini_chain(&self, container: &[u8], start: u32, limit: usize) -> Result<Vec<u8>, String> {
|
||||
let mut out = Vec::new();
|
||||
let mut id = start;
|
||||
let mut seen = vec![false; self.mini_fat.len()];
|
||||
let mut cut = false;
|
||||
while id != END_OF_CHAIN && out.len() < limit {
|
||||
if cut {
|
||||
return Err("damaged compound file: mini sector out of range".into());
|
||||
}
|
||||
if id >= MAX_REG_SECT {
|
||||
return Err("damaged compound file: broken mini sector chain".into());
|
||||
}
|
||||
match seen.get_mut(id as usize) {
|
||||
Some(v) if !*v => *v = true,
|
||||
Some(_) => return Err("damaged compound file: mini sector chain loops".into()),
|
||||
None => return Err("damaged compound file: mini sector missing from the mini FAT".into()),
|
||||
}
|
||||
let at = (id as usize).checked_mul(self.mini_sector_size);
|
||||
let s = at
|
||||
.and_then(|a| container.get(a..a.checked_add(self.mini_sector_size)?.min(container.len())))
|
||||
.ok_or("damaged compound file: mini sector out of range")?;
|
||||
cut = s.len() < self.mini_sector_size;
|
||||
out.extend_from_slice(s);
|
||||
id = *self.mini_fat.get(id as usize).ok_or("damaged compound file: mini sector missing from the mini FAT")?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The contents of the stream called `name` (case-insensitive) directly under the root
|
||||
/// storage, or `None` when there is no such stream.
|
||||
/// The directory entry of the stream called `name` (case-insensitive) directly under the
|
||||
/// root storage.
|
||||
fn root_entry(&self, name: &str) -> Result<Option<&DirEntry>, String> {
|
||||
let Some(root) = self.entries.first() else { return Ok(None) };
|
||||
// Walk the root's children (a red-black tree through left/right siblings), bounded.
|
||||
let mut stack = vec![root.child];
|
||||
let mut seen = 0usize;
|
||||
while let Some(i) = stack.pop() {
|
||||
if i == NO_STREAM {
|
||||
continue;
|
||||
}
|
||||
seen += 1;
|
||||
if seen > self.entries.len() {
|
||||
return Err("damaged compound file: directory tree loops".into());
|
||||
}
|
||||
let Some(e) = self.entries.get(i as usize) else { continue };
|
||||
if e.kind == STREAM_OBJECT && e.name.eq_ignore_ascii_case(name) {
|
||||
return Ok(Some(e));
|
||||
}
|
||||
stack.push(e.left);
|
||||
stack.push(e.right);
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Whether the root storage holds a stream called `name`, without reading it.
|
||||
pub(crate) fn has_root_stream(&self, name: &str) -> bool {
|
||||
matches!(self.root_entry(name), Ok(Some(_)))
|
||||
}
|
||||
|
||||
/// The contents of the stream called `name` (case-insensitive) directly under the root
|
||||
/// storage, or `None` when there is no such stream.
|
||||
pub(crate) fn root_stream(&self, name: &str) -> Result<Option<Vec<u8>>, String> {
|
||||
let Some(root) = self.entries.first() else { return Ok(None) };
|
||||
let found = self.root_entry(name)?;
|
||||
let Some(e) = found else { return Ok(None) };
|
||||
if e.size > MAX_STREAM {
|
||||
return Err("the workbook stream is larger than 1 GiB".into());
|
||||
}
|
||||
let size = usize::try_from(e.size).map_err(|_| "stream too large".to_string())?;
|
||||
let mut data = if e.size < self.mini_cutoff {
|
||||
let container = self.read_chain(root.start, root.size)?;
|
||||
self.read_mini_chain(&container, e.start, size)?
|
||||
} else {
|
||||
self.read_chain(e.start, e.size)?
|
||||
};
|
||||
if data.len() < size {
|
||||
return Err("damaged compound file: a stream is shorter than its declared size (the file may be truncated)".into());
|
||||
}
|
||||
data.truncate(size);
|
||||
Ok(Some(data))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A minimal valid compound file (512-byte sectors: FAT at sector 0, directory at sector 1)
|
||||
/// whose directory holds a root entry plus the given stream names.
|
||||
pub(crate) fn build(streams: &[&str]) -> Vec<u8> {
|
||||
let mut b = vec![0u8; 512 * 3];
|
||||
b[..8].copy_from_slice(&SIGNATURE);
|
||||
b[24..26].copy_from_slice(&0x3Eu16.to_le_bytes()); // minor version
|
||||
b[26..28].copy_from_slice(&3u16.to_le_bytes()); // major version
|
||||
b[28..30].copy_from_slice(&0xFFFEu16.to_le_bytes()); // byte order
|
||||
b[30..32].copy_from_slice(&9u16.to_le_bytes()); // sector shift
|
||||
b[32..34].copy_from_slice(&6u16.to_le_bytes()); // mini sector shift
|
||||
b[44..48].copy_from_slice(&1u32.to_le_bytes()); // FAT sectors
|
||||
b[48..52].copy_from_slice(&1u32.to_le_bytes()); // first directory sector
|
||||
b[60..64].copy_from_slice(&END_OF_CHAIN.to_le_bytes()); // no mini FAT
|
||||
b[68..72].copy_from_slice(&END_OF_CHAIN.to_le_bytes()); // no DIFAT sectors
|
||||
for i in 0..109 {
|
||||
b[76 + i * 4..80 + i * 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
|
||||
}
|
||||
b[76..80].copy_from_slice(&0u32.to_le_bytes()); // FAT lives in sector 0
|
||||
// FAT (sector 0, at byte 512): sector 0 is a FAT sector, sector 1 ends its chain.
|
||||
for i in 0..128 {
|
||||
b[512 + i * 4..516 + i * 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
|
||||
}
|
||||
b[512..516].copy_from_slice(&0xFFFF_FFFDu32.to_le_bytes());
|
||||
b[516..520].copy_from_slice(&END_OF_CHAIN.to_le_bytes());
|
||||
// Directory (sector 1, at byte 1024).
|
||||
for (k, name) in std::iter::once("Root Entry").chain(streams.iter().copied()).take(4).enumerate() {
|
||||
let e = 1024 + k * 128;
|
||||
let units: Vec<u16> = name.encode_utf16().take(31).collect();
|
||||
for (i, u) in units.iter().enumerate() {
|
||||
b[e + i * 2..e + i * 2 + 2].copy_from_slice(&u.to_le_bytes());
|
||||
}
|
||||
b[e + 64..e + 66].copy_from_slice(&((units.len() as u16 + 1) * 2).to_le_bytes());
|
||||
b[e + 66] = if k == 0 { 5 } else { 2 };
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_directory_names() {
|
||||
let b = build(&["EncryptionInfo", "EncryptedPackage"]);
|
||||
assert_eq!(directory_names(&b), Some(vec!["Root Entry".into(), "EncryptionInfo".into(), "EncryptedPackage".into()]));
|
||||
assert!(is_encrypted(&b));
|
||||
assert!(!is_encrypted(&build(&["Workbook", "\u{5}SummaryInformation"])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_headers_never_panic() {
|
||||
let mut b = build(&["Workbook"]);
|
||||
for at in [30usize, 48, 68, 76, 512, 516, 1024 + 64] {
|
||||
for v in [0u32, 1, 7, 0xFFFF_FFFA, 0xFFFF_FFFF, 0x7FFF_FFFF] {
|
||||
let mut c = b.clone();
|
||||
c[at..at + 4].copy_from_slice(&v.to_le_bytes());
|
||||
let _ = is_encrypted(&c);
|
||||
for cut in [0usize, 8, 100, 600, 1100] {
|
||||
let _ = is_encrypted(&c[..cut.min(c.len())]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A directory chain that loops back on itself stops at the bound.
|
||||
b[516..520].copy_from_slice(&1u32.to_le_bytes());
|
||||
assert!(!is_encrypted(&b));
|
||||
}
|
||||
|
||||
/// `compound_file` with one regular-sector stream of `len` bytes (FAT in sector 0, directory
|
||||
/// in sector 1, the stream from sector 2), cut right after the stream's last byte, the way
|
||||
/// some writers leave the final sector unpadded.
|
||||
fn unpadded(len: usize) -> (Vec<u8>, Vec<u8>) {
|
||||
let data: Vec<u8> = (0..len).map(|i| (i % 251) as u8).collect();
|
||||
let mut file = crate::testing::compound_file(&[("Workbook", &data)]);
|
||||
file.truncate(512 * 3 + len);
|
||||
(file, data)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unpadded_last_sector_still_reads() {
|
||||
let (file, data) = unpadded(5000);
|
||||
assert_ne!(file.len() % 512, 0);
|
||||
let cf = Compound::open(&file).unwrap();
|
||||
assert_eq!(cf.root_stream("Workbook").unwrap().unwrap(), data);
|
||||
// Bytes the stream needs that the file doesn't hold are still damage.
|
||||
let cf = Compound::open(&file[..file.len() - 1]).unwrap();
|
||||
assert!(cf.root_stream("Workbook").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unpadded_last_mini_sector_still_reads() {
|
||||
let data: Vec<u8> = (0..1000u32).map(|i| (i % 251) as u8).collect();
|
||||
let mut file = crate::testing::compound_file(&[("Workbook", &data)]);
|
||||
// Header, FAT, directory, mini FAT, then the mini stream holding the stream's bytes.
|
||||
file.truncate(512 * 4 + data.len());
|
||||
let cf = Compound::open(&file).unwrap();
|
||||
assert_eq!(cf.root_stream("Workbook").unwrap().unwrap(), data);
|
||||
let cf = Compound::open(&file[..file.len() - 1]).unwrap();
|
||||
assert!(cf.root_stream("Workbook").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cut_sector_inside_a_chain_is_damage() {
|
||||
let (mut file, _) = unpadded(5000);
|
||||
// The cut sector (11) points on to the directory sector and the stream claims those
|
||||
// bytes too: the partial sector would splice 392 bytes into the middle of the stream.
|
||||
file[512 + 11 * 4..512 + 12 * 4].copy_from_slice(&1u32.to_le_bytes());
|
||||
file[1024 + 128 + 120..1024 + 128 + 124].copy_from_slice(&5512u32.to_le_bytes());
|
||||
let cf = Compound::open(&file).unwrap();
|
||||
assert!(cf.root_stream("Workbook").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cut_mini_sector_inside_a_chain_is_damage() {
|
||||
let small = [7u8; 100];
|
||||
let data: Vec<u8> = (0..1000u32).map(|i| (i % 251) as u8).collect();
|
||||
let mut file = crate::testing::compound_file(&[("Small", &small), ("Workbook", &data)]);
|
||||
// "Small" holds mini sectors 0-1, "Workbook" 2-17; the mini stream starts at sector 3.
|
||||
file.truncate(512 * 4 + 128 + data.len());
|
||||
assert_eq!(Compound::open(&file).unwrap().root_stream("Workbook").unwrap().unwrap(), data);
|
||||
// The cut mini sector (17) points on to sector 0 and the stream claims those bytes too.
|
||||
file[1536 + 17 * 4..1536 + 18 * 4].copy_from_slice(&0u32.to_le_bytes());
|
||||
file[1024 + 2 * 128 + 120..1024 + 2 * 128 + 124].copy_from_slice(&1064u32.to_le_bytes());
|
||||
let cf = Compound::open(&file).unwrap();
|
||||
assert!(cf.root_stream("Workbook").is_err());
|
||||
}
|
||||
}
|
||||
+19
-511
@@ -1,42 +1,7 @@
|
||||
//! Password-encrypted workbooks. Excel wraps the ordinary OOXML package in a compound file with
|
||||
//! two streams: `EncryptionInfo` (how the key comes from the password, and a verifier that tells
|
||||
//! a wrong password) and `EncryptedPackage` (the package, encrypted with AES). Agile encryption
|
||||
//! (Excel 2010 onward) and Standard encryption (Excel 2007) are read; RC4, Extensible and
|
||||
//! certificate-only encryption are reported as unsupported.
|
||||
//!
|
||||
//! Clean-room from Microsoft's public [MS-OFFCRYPTO] specification. Every read is
|
||||
//! bounds-checked, and the costly inputs (spin count, stated sizes) are capped.
|
||||
|
||||
use aes::cipher::{BlockCipherDecrypt, KeyInit};
|
||||
use sha2::Digest;
|
||||
|
||||
use crate::cfb::Compound;
|
||||
use crate::xml::El;
|
||||
|
||||
/// Spin counts above this are refused (the cap sheet-protection hashes use too).
|
||||
const MAX_SPIN_COUNT: u32 = 10_000_000;
|
||||
/// Standard encryption always iterates this many times.
|
||||
const STANDARD_SPIN_COUNT: u32 = 50_000;
|
||||
/// Agile packages are encrypted in segments of this many bytes, each with its own IV.
|
||||
pub(crate) const SEGMENT: usize = 4096;
|
||||
/// AES block size.
|
||||
const BLOCK: usize = 16;
|
||||
|
||||
/// Agile block keys ([MS-OFFCRYPTO] 2.3.4.13): one per value derived from the password.
|
||||
pub(crate) const BLOCK_VERIFIER_INPUT: [u8; 8] = [0xFE, 0xA7, 0xD2, 0x76, 0x3B, 0x4B, 0x9E, 0x79];
|
||||
pub(crate) const BLOCK_VERIFIER_HASH: [u8; 8] = [0xD7, 0xAA, 0x0F, 0x6D, 0x30, 0x61, 0x34, 0x4E];
|
||||
pub(crate) const BLOCK_KEY: [u8; 8] = [0x14, 0x6E, 0x0B, 0xE7, 0xAB, 0xAC, 0xD0, 0xD6];
|
||||
/// The two dataIntegrity block keys ([MS-OFFCRYPTO] 2.3.4.14), one per HMAC field.
|
||||
pub(crate) const BLOCK_HMAC_KEY: [u8; 8] = [0x5F, 0xB2, 0xAD, 0x01, 0x0C, 0xB9, 0xE1, 0xF6];
|
||||
pub(crate) const BLOCK_HMAC_VALUE: [u8; 8] = [0xA0, 0x67, 0x7F, 0x02, 0xB2, 0x2C, 0x84, 0x33];
|
||||
|
||||
/// The password key encryptor's URI (the other kind is certificate-based).
|
||||
pub(crate) const PASSWORD_ENCRYPTOR: &str = "http://schemas.microsoft.com/office/2006/keyEncryptor/password";
|
||||
|
||||
/// Standard encryption header flags ([MS-OFFCRYPTO] 2.3.1).
|
||||
pub(crate) const F_CRYPTOAPI: u32 = 0x04;
|
||||
const F_EXTERNAL: u32 = 0x10;
|
||||
pub(crate) const F_AES: u32 = 0x20;
|
||||
//! two streams, `EncryptionInfo` and `EncryptedPackage`. `craft-offcrypto` reads them (Agile and
|
||||
//! Standard encryption; RC4, Extensible, certificate-only and rights-managed files are named as
|
||||
//! unsupported); this module keeps GridCraft's error type.
|
||||
|
||||
/// Why an encrypted workbook couldn't be decrypted.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
@@ -50,489 +15,32 @@ pub enum DecryptError {
|
||||
Malformed(String),
|
||||
}
|
||||
|
||||
fn malformed(m: impl Into<String>) -> DecryptError {
|
||||
DecryptError::Malformed(m.into())
|
||||
impl From<craft_offcrypto::Error> for DecryptError {
|
||||
fn from(e: craft_offcrypto::Error) -> Self {
|
||||
use craft_offcrypto::Error as E;
|
||||
match e {
|
||||
E::WrongPassword | E::PasswordRequired => DecryptError::WrongPassword,
|
||||
E::Unsupported(scheme) => DecryptError::Unsupported(scheme),
|
||||
E::Malformed(detail) | E::TooLarge(detail) => DecryptError::Malformed(detail),
|
||||
e => DecryptError::Malformed(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The plain OOXML package (a zip) inside an encrypted workbook.
|
||||
pub fn decrypt(bytes: &[u8], password: &str) -> Result<Vec<u8>, DecryptError> {
|
||||
let (info, package) = streams(bytes)?;
|
||||
decrypt_streams(&info, &package, password)
|
||||
craft_offcrypto::decrypt(bytes, Some(password)).map_err(DecryptError::from)
|
||||
}
|
||||
|
||||
/// Whether the right password could decrypt this encrypted workbook, checked without one. An
|
||||
/// error names a scheme no password opens (RC4, rights management…) or damage, so callers can
|
||||
/// say so instead of asking for a password first.
|
||||
pub fn check_encryption(bytes: &[u8]) -> Result<(), DecryptError> {
|
||||
let (info, _) = streams(bytes)?;
|
||||
match scheme(&info)? {
|
||||
Scheme::Agile(xml) => agile_descriptor(xml).map(|_| ()),
|
||||
Scheme::Standard(info) => standard_header(info).map(|_| ()),
|
||||
}
|
||||
craft_offcrypto::check(bytes).map_err(DecryptError::from)
|
||||
}
|
||||
|
||||
/// The `EncryptionInfo` and `EncryptedPackage` streams. Rights management (IRM) keeps the
|
||||
/// package but describes its encryption elsewhere, with no `EncryptionInfo`.
|
||||
fn streams(bytes: &[u8]) -> Result<(Vec<u8>, Vec<u8>), DecryptError> {
|
||||
let cf = Compound::open(bytes).map_err(malformed)?;
|
||||
let info = cf.root_stream("EncryptionInfo").map_err(malformed)?;
|
||||
let package = cf.root_stream("EncryptedPackage").map_err(malformed)?.ok_or_else(|| malformed("no EncryptedPackage stream"))?;
|
||||
let info = info.ok_or_else(|| DecryptError::Unsupported("rights-management".into()))?;
|
||||
Ok((info, package))
|
||||
/// Whether a compound file holds an encryption stream, at any depth. A damaged directory falls
|
||||
/// back to a search for the stream names, so the file is still reported as encrypted.
|
||||
pub(crate) fn is_encrypted(bytes: &[u8]) -> bool {
|
||||
["EncryptedPackage", "EncryptionInfo"].iter().any(|n| craft_cfb::has_entry_named(bytes, n, &craft_cfb::Limits::default()))
|
||||
}
|
||||
|
||||
/// Decrypts from the two streams' contents.
|
||||
pub(crate) fn decrypt_streams(info: &[u8], package: &[u8], password: &str) -> Result<Vec<u8>, DecryptError> {
|
||||
match scheme(info)? {
|
||||
Scheme::Agile(xml) => agile(xml, package, password),
|
||||
Scheme::Standard(info) => standard(info, package, password),
|
||||
}
|
||||
}
|
||||
|
||||
/// The two schemes read, with the part of `EncryptionInfo` each one parses.
|
||||
enum Scheme<'a> {
|
||||
/// The XML descriptor after the version and reserved field.
|
||||
Agile(&'a [u8]),
|
||||
/// Everything after the version.
|
||||
Standard(&'a [u8]),
|
||||
}
|
||||
|
||||
fn scheme(info: &[u8]) -> Result<Scheme<'_>, DecryptError> {
|
||||
let short = || malformed("truncated EncryptionInfo");
|
||||
let major = u16_at(info, 0).ok_or_else(short)?;
|
||||
let minor = u16_at(info, 2).ok_or_else(short)?;
|
||||
match (major, minor) {
|
||||
(4, 4) => Ok(Scheme::Agile(info.get(8..).ok_or_else(short)?)),
|
||||
(2..=4, 2) => Ok(Scheme::Standard(info.get(4..).ok_or_else(short)?)),
|
||||
(3 | 4, 3) => Err(DecryptError::Unsupported("Extensible".into())),
|
||||
_ => Err(DecryptError::Unsupported(format!("version {major}.{minor}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn u16_at(b: &[u8], at: usize) -> Option<u16> {
|
||||
let s = b.get(at..at.checked_add(2)?)?;
|
||||
Some(u16::from_le_bytes([*s.first()?, *s.get(1)?]))
|
||||
}
|
||||
|
||||
fn u32_at(b: &[u8], at: usize) -> Option<u32> {
|
||||
let s = b.get(at..at.checked_add(4)?)?;
|
||||
Some(u32::from_le_bytes([*s.first()?, *s.get(1)?, *s.get(2)?, *s.get(3)?]))
|
||||
}
|
||||
|
||||
/// The plaintext size an `EncryptedPackage` states, and the ciphertext after it. A size larger
|
||||
/// than the ciphertext is damage, so it never drives an allocation.
|
||||
fn package_parts(package: &[u8]) -> Result<(usize, &[u8]), DecryptError> {
|
||||
let head: [u8; 8] = package.get(..8).and_then(|h| h.try_into().ok()).ok_or_else(|| malformed("truncated EncryptedPackage"))?;
|
||||
let data = package.get(8..).unwrap_or_default();
|
||||
let size = usize::try_from(u64::from_le_bytes(head)).unwrap_or(usize::MAX);
|
||||
if size > data.len() {
|
||||
return Err(malformed("the package is shorter than its stated size"));
|
||||
}
|
||||
Ok((size, data))
|
||||
}
|
||||
|
||||
// ---- hashing -------------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Hash {
|
||||
Sha1,
|
||||
Sha256,
|
||||
Sha384,
|
||||
Sha512,
|
||||
}
|
||||
|
||||
impl Hash {
|
||||
fn from_name(name: &str) -> Result<Hash, DecryptError> {
|
||||
Ok(match name {
|
||||
"SHA1" | "SHA-1" => Hash::Sha1,
|
||||
"SHA256" | "SHA-256" => Hash::Sha256,
|
||||
"SHA384" | "SHA-384" => Hash::Sha384,
|
||||
"SHA512" | "SHA-512" => Hash::Sha512,
|
||||
other => return Err(DecryptError::Unsupported(format!("{other} hash"))),
|
||||
})
|
||||
}
|
||||
|
||||
/// The digest of the concatenated `parts`.
|
||||
pub(crate) fn digest(self, parts: &[&[u8]]) -> Vec<u8> {
|
||||
fn run<D: Digest>(parts: &[&[u8]]) -> Vec<u8> {
|
||||
let mut d = D::new();
|
||||
for p in parts {
|
||||
d.update(p);
|
||||
}
|
||||
d.finalize().to_vec()
|
||||
}
|
||||
match self {
|
||||
Hash::Sha1 => run::<sha1::Sha1>(parts),
|
||||
Hash::Sha256 => run::<sha2::Sha256>(parts),
|
||||
Hash::Sha384 => run::<sha2::Sha384>(parts),
|
||||
Hash::Sha512 => run::<sha2::Sha512>(parts),
|
||||
}
|
||||
}
|
||||
|
||||
/// The digest's length in bytes.
|
||||
fn digest_len(self) -> usize {
|
||||
match self {
|
||||
Hash::Sha1 => 20,
|
||||
Hash::Sha256 => 32,
|
||||
Hash::Sha384 => 48,
|
||||
Hash::Sha512 => 64,
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes the algorithm's compression function takes: 128 for SHA-384/512, else 64.
|
||||
fn block_size(self) -> usize {
|
||||
match self {
|
||||
Hash::Sha384 | Hash::Sha512 => 128,
|
||||
Hash::Sha1 | Hash::Sha256 => 64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// H0 = H(salt ‖ UTF-16LE(password)), then Hn = H(LE32(n - 1) ‖ Hn-1) for `spin` rounds.
|
||||
pub(crate) fn iterated_hash(hash: Hash, password: &str, salt: &[u8], spin: u32) -> Vec<u8> {
|
||||
let pw: Vec<u8> = password.encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||
let mut h = hash.digest(&[salt, &pw]);
|
||||
for i in 0..spin {
|
||||
h = hash.digest(&[&i.to_le_bytes(), &h]);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// H(a ‖ b), truncated or padded with 0x36 to `len`: Agile's derived keys and IVs.
|
||||
pub(crate) fn derive(hash: Hash, a: &[u8], b: &[u8], len: usize) -> Vec<u8> {
|
||||
let mut k = hash.digest(&[a, b]);
|
||||
k.resize(len, 0x36);
|
||||
k
|
||||
}
|
||||
|
||||
/// HMAC-SHA-`hash` ([RFC2104]), as Agile's dataIntegrity uses.
|
||||
pub(crate) fn hmac(hash: Hash, key: &[u8], message: &[u8]) -> Vec<u8> {
|
||||
let bs = hash.block_size();
|
||||
let mut k = key.to_vec();
|
||||
if k.len() > bs {
|
||||
k = hash.digest(&[&k]);
|
||||
}
|
||||
k.resize(bs, 0);
|
||||
let pad = |x: u8| k.iter().map(|b| b ^ x).collect::<Vec<u8>>();
|
||||
let inner = hash.digest(&[&pad(0x36), message]);
|
||||
hash.digest(&[&pad(0x5C), &inner])
|
||||
}
|
||||
|
||||
/// The Standard encryption key ([MS-OFFCRYPTO] 2.3.4.7): SHA-1 iterated 50,000 times, the
|
||||
/// block-0 hash, then the 0x36 / 0x5C expansion, truncated to `key_len`.
|
||||
pub(crate) fn standard_key(password: &str, salt: &[u8], key_len: usize) -> Vec<u8> {
|
||||
let h = iterated_hash(Hash::Sha1, password, salt, STANDARD_SPIN_COUNT);
|
||||
let h = Hash::Sha1.digest(&[&h, &0u32.to_le_bytes()]);
|
||||
let expand = |pad: u8| {
|
||||
let mut buf = [pad; 64];
|
||||
for (b, x) in buf.iter_mut().zip(&h) {
|
||||
*b ^= x;
|
||||
}
|
||||
Hash::Sha1.digest(&[&buf])
|
||||
};
|
||||
let mut key = expand(0x36);
|
||||
key.extend(expand(0x5C));
|
||||
key.truncate(key_len);
|
||||
key
|
||||
}
|
||||
|
||||
// ---- AES -----------------------------------------------------------------------------------
|
||||
|
||||
enum Aes {
|
||||
A128(aes::Aes128),
|
||||
A192(aes::Aes192),
|
||||
A256(aes::Aes256),
|
||||
}
|
||||
|
||||
impl Aes {
|
||||
fn new(key: &[u8]) -> Result<Aes, DecryptError> {
|
||||
let bad = |_| malformed("bad AES key");
|
||||
Ok(match key.len() {
|
||||
16 => Aes::A128(aes::Aes128::new_from_slice(key).map_err(bad)?),
|
||||
24 => Aes::A192(aes::Aes192::new_from_slice(key).map_err(bad)?),
|
||||
32 => Aes::A256(aes::Aes256::new_from_slice(key).map_err(bad)?),
|
||||
n => return Err(malformed(format!("a {n}-byte AES key"))),
|
||||
})
|
||||
}
|
||||
|
||||
fn decrypt_block(&self, block: &mut [u8; BLOCK]) {
|
||||
let b = aes::Block::cast_from_core_mut(block);
|
||||
match self {
|
||||
Aes::A128(c) => c.decrypt_block(b),
|
||||
Aes::A192(c) => c.decrypt_block(b),
|
||||
Aes::A256(c) => c.decrypt_block(b),
|
||||
}
|
||||
}
|
||||
|
||||
/// ECB: each block on its own. `data` must be whole blocks.
|
||||
fn decrypt_ecb(&self, data: &mut [u8]) -> Result<(), DecryptError> {
|
||||
let (blocks, rest) = data.as_chunks_mut::<BLOCK>();
|
||||
if !rest.is_empty() {
|
||||
return Err(malformed("ciphertext isn't whole AES blocks"));
|
||||
}
|
||||
blocks.iter_mut().for_each(|b| self.decrypt_block(b));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// CBC: each block XORed with the previous ciphertext block (the IV for the first).
|
||||
fn decrypt_cbc(&self, iv: &[u8], data: &mut [u8]) -> Result<(), DecryptError> {
|
||||
let mut prev: [u8; BLOCK] = iv.try_into().map_err(|_| malformed("an IV that isn't one AES block"))?;
|
||||
let (blocks, rest) = data.as_chunks_mut::<BLOCK>();
|
||||
if !rest.is_empty() {
|
||||
return Err(malformed("ciphertext isn't whole AES blocks"));
|
||||
}
|
||||
for b in blocks {
|
||||
let cipher = *b;
|
||||
self.decrypt_block(b);
|
||||
b.iter_mut().zip(prev).for_each(|(x, p)| *x ^= p);
|
||||
prev = cipher;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Standard encryption -------------------------------------------------------------------
|
||||
|
||||
/// What a Standard `EncryptionInfo` holds: the key size and the password verifier.
|
||||
struct StandardHeader<'a> {
|
||||
key_len: usize,
|
||||
salt: &'a [u8],
|
||||
verifier: &'a [u8],
|
||||
verifier_hash: &'a [u8],
|
||||
}
|
||||
|
||||
/// `info` starts after the version: flags, header size, header, verifier.
|
||||
fn standard_header(info: &[u8]) -> Result<StandardHeader<'_>, DecryptError> {
|
||||
let short = || malformed("truncated Standard encryption header");
|
||||
let flags = u32_at(info, 0).ok_or_else(short)?;
|
||||
if flags & F_EXTERNAL != 0 {
|
||||
return Err(DecryptError::Unsupported("external".into()));
|
||||
}
|
||||
if flags & F_AES == 0 || flags & F_CRYPTOAPI == 0 {
|
||||
return Err(DecryptError::Unsupported("RC4".into()));
|
||||
}
|
||||
let header_size = usize::try_from(u32_at(info, 4).ok_or_else(short)?).map_err(|_| short())?;
|
||||
let header = info.get(8..8usize.checked_add(header_size).ok_or_else(short)?).ok_or_else(short)?;
|
||||
let alg_id = u32_at(header, 8).ok_or_else(short)?;
|
||||
let alg_hash = u32_at(header, 12).ok_or_else(short)?;
|
||||
let key_bits = u32_at(header, 16).ok_or_else(short)?;
|
||||
let key_len = match (alg_id, key_bits) {
|
||||
(0x660E | 0, 128) => 16,
|
||||
(0x660F, 192) => 24,
|
||||
(0x6610, 256) => 32,
|
||||
(0x660E..=0x6610, _) => return Err(malformed(format!("AES with a {key_bits}-bit key"))),
|
||||
_ => return Err(DecryptError::Unsupported(format!("cipher 0x{alg_id:04X}"))),
|
||||
};
|
||||
if alg_hash != 0x8004 && alg_hash != 0 {
|
||||
return Err(DecryptError::Unsupported(format!("hash 0x{alg_hash:04X}")));
|
||||
}
|
||||
// Verifier: salt size, salt, encrypted verifier, verifier hash size, encrypted hash.
|
||||
let v = info.get(8 + header_size..).ok_or_else(short)?;
|
||||
if u32_at(v, 0) != Some(16) {
|
||||
return Err(malformed("a Standard salt that isn't 16 bytes"));
|
||||
}
|
||||
let salt = v.get(4..20).ok_or_else(short)?;
|
||||
let verifier = v.get(20..36).ok_or_else(short)?;
|
||||
let verifier_hash = v.get(40..72).ok_or_else(short)?;
|
||||
Ok(StandardHeader { key_len, salt, verifier, verifier_hash })
|
||||
}
|
||||
|
||||
fn standard(info: &[u8], package: &[u8], password: &str) -> Result<Vec<u8>, DecryptError> {
|
||||
let h = standard_header(info)?;
|
||||
let (mut verifier, mut verifier_hash) = (h.verifier.to_vec(), h.verifier_hash.to_vec());
|
||||
let aes = Aes::new(&standard_key(password, h.salt, h.key_len))?;
|
||||
aes.decrypt_ecb(&mut verifier)?;
|
||||
aes.decrypt_ecb(&mut verifier_hash)?;
|
||||
if verifier_hash.get(..20) != Some(Hash::Sha1.digest(&[&verifier]).as_slice()) {
|
||||
return Err(DecryptError::WrongPassword);
|
||||
}
|
||||
let (size, data) = package_parts(package)?;
|
||||
let mut out = data.get(..size.div_ceil(BLOCK) * BLOCK).ok_or_else(|| malformed("the package isn't whole AES blocks"))?.to_vec();
|
||||
aes.decrypt_ecb(&mut out)?;
|
||||
out.truncate(size);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---- Agile encryption ----------------------------------------------------------------------
|
||||
|
||||
/// The cipher parameters shared by `keyData` and a password `encryptedKey` element.
|
||||
pub(crate) struct Params {
|
||||
pub(crate) hash: Hash,
|
||||
pub(crate) salt: Vec<u8>,
|
||||
pub(crate) key_len: usize,
|
||||
}
|
||||
|
||||
impl Params {
|
||||
fn read(e: &El) -> Result<Params, DecryptError> {
|
||||
let cipher = e.attr("cipherAlgorithm").unwrap_or("AES");
|
||||
if cipher != "AES" {
|
||||
return Err(DecryptError::Unsupported(cipher.into()));
|
||||
}
|
||||
let chaining = e.attr("cipherChaining").unwrap_or("ChainingModeCBC");
|
||||
if chaining != "ChainingModeCBC" {
|
||||
return Err(DecryptError::Unsupported(format!("AES {chaining}")));
|
||||
}
|
||||
if e.attr_u32("blockSize") != Some(16) {
|
||||
return Err(malformed("an AES block size that isn't 16"));
|
||||
}
|
||||
let key_len = match e.attr_u32("keyBits") {
|
||||
Some(128) => 16,
|
||||
Some(192) => 24,
|
||||
Some(256) => 32,
|
||||
other => return Err(malformed(format!("AES with keyBits {}", other.unwrap_or(0)))),
|
||||
};
|
||||
let hash = Hash::from_name(e.attr("hashAlgorithm").unwrap_or("SHA1"))?;
|
||||
let salt = base64(e.attr("saltValue").ok_or_else(|| malformed("no saltValue"))?)?;
|
||||
if salt.is_empty() || salt.len() > 64 {
|
||||
return Err(malformed("a salt that isn't 1 to 64 bytes"));
|
||||
}
|
||||
Ok(Params { hash, salt, key_len })
|
||||
}
|
||||
|
||||
/// An IV: H(salt ‖ block key) for a block key, else the salt, sized to one AES block.
|
||||
pub(crate) fn iv(&self, block_key: Option<&[u8]>) -> Vec<u8> {
|
||||
match block_key {
|
||||
Some(k) => derive(self.hash, &self.salt, k, BLOCK),
|
||||
None => {
|
||||
let mut iv = self.salt.clone();
|
||||
iv.resize(BLOCK, 0x36);
|
||||
iv
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What an Agile descriptor holds: the package's cipher, the password key encryptor with its
|
||||
/// three encrypted values (verifier input, verifier hash, package key), and the package's
|
||||
/// integrity pair, which Excel always writes and the reader requires.
|
||||
struct AgileDescriptor {
|
||||
key_data: Params,
|
||||
key: Params,
|
||||
spin: u32,
|
||||
verifier_input: Vec<u8>,
|
||||
verifier_hash: Vec<u8>,
|
||||
key_value: Vec<u8>,
|
||||
hmac_key: Vec<u8>,
|
||||
hmac_value: Vec<u8>,
|
||||
}
|
||||
|
||||
/// `xml` is the XML encryption descriptor after the 8-byte version and reserved field.
|
||||
fn agile_descriptor(xml: &[u8]) -> Result<AgileDescriptor, DecryptError> {
|
||||
let root = crate::xml::parse(xml).map_err(|e| malformed(e.to_string()))?;
|
||||
let key_data = Params::read(root.child("keyData").ok_or_else(|| malformed("no keyData"))?)?;
|
||||
let encryptor = root
|
||||
.child("keyEncryptors")
|
||||
.and_then(|k| k.kids("keyEncryptor").find(|e| e.attr("uri") == Some(PASSWORD_ENCRYPTOR)))
|
||||
.ok_or_else(|| DecryptError::Unsupported("certificate".into()))?
|
||||
.child("encryptedKey")
|
||||
.ok_or_else(|| malformed("no encryptedKey"))?;
|
||||
let key = Params::read(encryptor)?;
|
||||
let spin = encryptor.attr_u32("spinCount").ok_or_else(|| malformed("no spinCount"))?;
|
||||
if spin > MAX_SPIN_COUNT {
|
||||
return Err(malformed(format!("a spin count of {spin}, more than the {MAX_SPIN_COUNT} allowed")));
|
||||
}
|
||||
let field = |name: &str| base64(encryptor.attr(name).ok_or_else(|| malformed(format!("no {name}")))?);
|
||||
// A descriptor without dataIntegrity can't prove its ciphertext is the one the password
|
||||
// protects: the verifier only authenticates the key, not the package ([MS-OFFCRYPTO] 2.3.4.14).
|
||||
let integrity = root.child("dataIntegrity").ok_or_else(|| malformed("no dataIntegrity"))?;
|
||||
let hmac_field = |attr: &str| {
|
||||
let v = base64(integrity.attr(attr).ok_or_else(|| malformed(format!("no {attr}")))?)?;
|
||||
if v.is_empty() || v.len() % BLOCK != 0 {
|
||||
return Err(malformed(format!("a whole-block {attr}")));
|
||||
}
|
||||
Ok(v)
|
||||
};
|
||||
Ok(AgileDescriptor {
|
||||
key_data,
|
||||
key,
|
||||
spin,
|
||||
verifier_input: field("encryptedVerifierHashInput")?,
|
||||
verifier_hash: field("encryptedVerifierHashValue")?,
|
||||
key_value: field("encryptedKeyValue")?,
|
||||
hmac_key: hmac_field("encryptedHmacKey")?,
|
||||
hmac_value: hmac_field("encryptedHmacValue")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Decrypts an Agile package ([MS-OFFCRYPTO] 2.3.4.10): checks the password against the
|
||||
/// verifier, unwraps the package key, verifies the dataIntegrity HMAC, then decrypts the segments.
|
||||
fn agile(xml: &[u8], package: &[u8], password: &str) -> Result<Vec<u8>, DecryptError> {
|
||||
let descriptor = agile_descriptor(xml)?;
|
||||
let AgileDescriptor { key_data, key, spin, verifier_input, verifier_hash, key_value, hmac_key, hmac_value } = descriptor;
|
||||
let h = iterated_hash(key.hash, password, &key.salt, spin);
|
||||
let iv = key.iv(None);
|
||||
let field = |data: &[u8], block_key: &[u8]| -> Result<Vec<u8>, DecryptError> {
|
||||
let mut data = data.to_vec();
|
||||
Aes::new(&derive(key.hash, &h, block_key, key.key_len))?.decrypt_cbc(&iv, &mut data)?;
|
||||
Ok(data)
|
||||
};
|
||||
let input = field(&verifier_input, &BLOCK_VERIFIER_INPUT)?;
|
||||
let expected = field(&verifier_hash, &BLOCK_VERIFIER_HASH)?;
|
||||
let input = input.get(..key.salt.len()).unwrap_or(&input);
|
||||
let actual = key.hash.digest(&[input]);
|
||||
if expected.get(..actual.len()) != Some(actual.as_slice()) {
|
||||
return Err(DecryptError::WrongPassword);
|
||||
}
|
||||
let secret = field(&key_value, &BLOCK_KEY)?;
|
||||
let aes = Aes::new(secret.get(..key_data.key_len).ok_or_else(|| malformed("a short encryptedKeyValue"))?)?;
|
||||
// Package integrity, [MS-OFFCRYPTO] 2.3.4.14: both HMAC fields are encrypted with the
|
||||
// package key (the verifier above only authenticates the password), computed over the whole
|
||||
// EncryptedPackage stream, StreamSize field included. The specification keys the HMAC with a
|
||||
// random salt-size array, but Excel's key is as long as the hash (64 bytes for SHA-512), so
|
||||
// both lengths are tried.
|
||||
let mut mac_field = hmac_key;
|
||||
aes.decrypt_cbc(&key_data.iv(Some(&BLOCK_HMAC_KEY)), &mut mac_field)?;
|
||||
let mut declared = hmac_value;
|
||||
aes.decrypt_cbc(&key_data.iv(Some(&BLOCK_HMAC_VALUE)), &mut declared)?;
|
||||
let verified = [key_data.hash.digest_len(), key_data.salt.len()].into_iter().any(|len| {
|
||||
let Some(key) = mac_field.get(..len) else { return false };
|
||||
let mac = hmac(key_data.hash, key, package);
|
||||
declared.get(..mac.len()) == Some(mac.as_slice())
|
||||
});
|
||||
if !verified {
|
||||
return Err(malformed("the decrypted data failed its integrity check"));
|
||||
}
|
||||
let (size, data) = package_parts(package)?;
|
||||
let mut out = Vec::with_capacity(size);
|
||||
for (i, segment) in data.chunks(SEGMENT).enumerate() {
|
||||
if out.len() >= size {
|
||||
break;
|
||||
}
|
||||
let index = u32::try_from(i).map_err(|_| malformed("too many segments"))?;
|
||||
let start = out.len();
|
||||
out.extend_from_slice(segment);
|
||||
let block = out.get_mut(start..).unwrap_or_default();
|
||||
aes.decrypt_cbc(&key_data.iv(Some(&index.to_le_bytes())), block)?;
|
||||
}
|
||||
out.truncate(size);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---- base64 --------------------------------------------------------------------------------
|
||||
|
||||
/// Standard base64 (whitespace ignored), as Agile's XML uses for every binary value.
|
||||
fn base64(s: &str) -> Result<Vec<u8>, DecryptError> {
|
||||
let mut out = Vec::with_capacity(s.len() / 4 * 3);
|
||||
let (mut buf, mut bits) = (0u32, 0u32);
|
||||
for c in s.bytes().filter(|c| !c.is_ascii_whitespace() && *c != b'=') {
|
||||
let v = match c {
|
||||
b'A'..=b'Z' => c - b'A',
|
||||
b'a'..=b'z' => c - b'a' + 26,
|
||||
b'0'..=b'9' => c - b'0' + 52,
|
||||
b'+' => 62,
|
||||
b'/' => 63,
|
||||
_ => return Err(malformed("invalid base64")),
|
||||
};
|
||||
buf = (buf << 6) | u32::from(v);
|
||||
bits += 6;
|
||||
if bits >= 8 {
|
||||
bits -= 8;
|
||||
out.push((buf >> bits) as u8);
|
||||
buf &= (1 << bits) - 1;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
//! Decryption tests. The expected keys and IVs below were computed independently with
|
||||
//! msoffcrypto-tool 6.0.0 (MIT) for the synthetic password and salts used here; the encrypted
|
||||
//! files are built in code by `crate::testing`. No Excel-produced file is used.
|
||||
|
||||
use super::*;
|
||||
use crate::testing::{agile_info, encrypt_agile, encrypt_agile_with_hmac_key, encrypt_standard, with_agile_header};
|
||||
|
||||
const PASSWORD: &str = "Pass1234";
|
||||
/// 16 zero bytes (whole AES blocks), a stand-in for a real integrity field where the values
|
||||
/// can't be reached anyway (scheme errors, descriptor-parse damage).
|
||||
const HMAC_STUB: &str = "AAAAAAAAAAAAAAAAAAAAAA==";
|
||||
|
||||
fn hex(s: &str) -> Vec<u8> {
|
||||
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
|
||||
}
|
||||
|
||||
fn salt() -> Vec<u8> {
|
||||
(0u8..16).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aes_decrypts_the_fips_197_vectors() {
|
||||
let mut b: [u8; 16] = hex("69c4e0d86a7b0430d8cdb78070b4c55a").try_into().unwrap();
|
||||
Aes::new(&hex("000102030405060708090a0b0c0d0e0f")).unwrap().decrypt_block(&mut b);
|
||||
assert_eq!(b.to_vec(), hex("00112233445566778899aabbccddeeff"));
|
||||
let mut b: [u8; 16] = hex("8ea2b7ca516745bfeafc49904b496089").try_into().unwrap();
|
||||
Aes::new(&hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")).unwrap().decrypt_block(&mut b);
|
||||
assert_eq!(b.to_vec(), hex("00112233445566778899aabbccddeeff"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agile_keys_match_msoffcrypto() {
|
||||
let h = iterated_hash(Hash::Sha512, PASSWORD, &salt(), 1000);
|
||||
assert_eq!(derive(Hash::Sha512, &h, &BLOCK_VERIFIER_INPUT, 32), hex("c57def04938e1e83497271f6ec099e890bfd2bd9cf05ff208f8b2405df361ae6"));
|
||||
assert_eq!(derive(Hash::Sha512, &h, &BLOCK_KEY, 32), hex("4ab08cf2ed95199d58d04f19331049a8a07cc1ff508431a1dd0785c84e261d03"));
|
||||
let h = iterated_hash(Hash::Sha1, PASSWORD, &salt(), 1000);
|
||||
assert_eq!(derive(Hash::Sha1, &h, &BLOCK_VERIFIER_INPUT, 16), hex("88c7567d028eae33a9a55f65729dcd2b"));
|
||||
let key_data = Params { hash: Hash::Sha512, salt: (16u8..32).collect(), key_len: 32 };
|
||||
assert_eq!(key_data.iv(Some(&1u32.to_le_bytes())), hex("da22ccba062aff577d207b2dee4e165b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agile_pads_short_hashes_with_0x36() {
|
||||
// SHA-1 gives 20 bytes; an AES-256 key needs 32, so 12 bytes of 0x36 follow.
|
||||
let k = derive(Hash::Sha1, b"a", b"b", 32);
|
||||
assert_eq!(k[..20], Hash::Sha1.digest(&[b"ab"])[..]);
|
||||
assert_eq!(k[20..], [0x36; 12]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_keys_match_msoffcrypto() {
|
||||
assert_eq!(standard_key(PASSWORD, &salt(), 16), hex("ae0b402aeb23a779471a130843e431d4"));
|
||||
assert_eq!(standard_key(PASSWORD, &salt(), 32), hex("ae0b402aeb23a779471a130843e431d47b416314b5146b491cb815283663d26f"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_decodes_and_rejects_junk() {
|
||||
assert_eq!(base64("AAECAwQFBgcICQoLDA0ODw==").unwrap(), salt());
|
||||
assert_eq!(base64(" AAEC\nAw== ").unwrap(), vec![0, 1, 2, 3]);
|
||||
assert!(matches!(base64("AA*C"), Err(DecryptError::Malformed(_))));
|
||||
}
|
||||
|
||||
/// A payload that isn't a whole number of segments or blocks.
|
||||
fn payload(len: usize) -> Vec<u8> {
|
||||
(0..len).map(|i| (i * 7 + i / 251) as u8).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agile_round_trips() {
|
||||
let plain = payload(10_000);
|
||||
let (info, package) = encrypt_agile(&plain, PASSWORD, 1000);
|
||||
assert_eq!(decrypt_streams(&info, &package, PASSWORD).unwrap(), plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_round_trips() {
|
||||
let plain = payload(10_000);
|
||||
let (info, package) = encrypt_standard(&plain, PASSWORD);
|
||||
assert_eq!(decrypt_streams(&info, &package, PASSWORD).unwrap(), plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_ascii_passwords_round_trip() {
|
||||
// UTF-16LE, surrogate pairs included: "Pässwörd€" plus an emoji outside the BMP.
|
||||
let pw = "P\u{e4}ssw\u{f6}rd\u{20ac}\u{1F511}";
|
||||
let (info, package) = encrypt_agile(&payload(100), pw, 1000);
|
||||
assert_eq!(decrypt_streams(&info, &package, pw).unwrap(), payload(100));
|
||||
assert_eq!(decrypt_streams(&info, &package, "Passwrd"), Err(DecryptError::WrongPassword));
|
||||
let (info, package) = encrypt_standard(&payload(100), pw);
|
||||
assert_eq!(decrypt_streams(&info, &package, pw).unwrap(), payload(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrong_password_is_reported_as_such() {
|
||||
let (info, package) = encrypt_agile(&payload(100), PASSWORD, 1000);
|
||||
assert_eq!(decrypt_streams(&info, &package, "pass1234"), Err(DecryptError::WrongPassword));
|
||||
assert_eq!(decrypt_streams(&info, &package, ""), Err(DecryptError::WrongPassword));
|
||||
let (info, package) = encrypt_standard(&payload(100), PASSWORD);
|
||||
assert_eq!(decrypt_streams(&info, &package, "Pass12345"), Err(DecryptError::WrongPassword));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_schemes_are_named() {
|
||||
let unsupported = |info: &[u8]| match decrypt_streams(info, &[0; 8], PASSWORD) {
|
||||
Err(DecryptError::Unsupported(s)) => s,
|
||||
other => panic!("expected Unsupported, got {other:?}"),
|
||||
};
|
||||
let (mut rc4, _) = encrypt_standard(&payload(10), PASSWORD);
|
||||
rc4[4..8].copy_from_slice(&F_CRYPTOAPI.to_le_bytes()); // fCryptoAPI without fAES
|
||||
assert_eq!(unsupported(&rc4), "RC4");
|
||||
assert_eq!(unsupported(&[3, 0, 3, 0, 0, 0, 0, 0]), "Extensible");
|
||||
assert_eq!(unsupported(&[1, 0, 1, 0, 0, 0, 0, 0]), "version 1.1");
|
||||
let des = agile_info(PASSWORD, 1000, HMAC_STUB, HMAC_STUB).replace("cipherAlgorithm=\"AES\"", "cipherAlgorithm=\"3DES\"");
|
||||
assert_eq!(unsupported(&with_agile_header(&des)), "3DES");
|
||||
let cert = agile_info(PASSWORD, 1000, HMAC_STUB, HMAC_STUB).replace("keyEncryptor/password", "keyEncryptor/certificate");
|
||||
assert_eq!(unsupported(&with_agile_header(&cert)), "certificate");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_files_are_malformed_not_panics() {
|
||||
let is_malformed = |r: Result<Vec<u8>, DecryptError>| matches!(r, Err(DecryptError::Malformed(_)));
|
||||
let plain = payload(5000);
|
||||
for (info, package) in [encrypt_agile(&plain, PASSWORD, 1000), encrypt_standard(&plain, PASSWORD)] {
|
||||
// Every truncation of either stream.
|
||||
for cut in (0..info.len()).step_by(7) {
|
||||
let _ = decrypt_streams(&info[..cut], &package, PASSWORD);
|
||||
}
|
||||
for cut in (0..package.len()).step_by(97) {
|
||||
let _ = decrypt_streams(&info, &package[..cut], PASSWORD);
|
||||
}
|
||||
// A stated size past the ciphertext never drives an allocation.
|
||||
let mut huge = package.clone();
|
||||
huge[..8].copy_from_slice(&u64::MAX.to_le_bytes());
|
||||
assert!(is_malformed(decrypt_streams(&info, &huge, PASSWORD)));
|
||||
// Ciphertext that isn't whole AES blocks.
|
||||
let mut ragged = package.clone();
|
||||
ragged.truncate(ragged.len() - 3);
|
||||
let len = (ragged.len() - 8) as u64;
|
||||
ragged[..8].copy_from_slice(&len.to_le_bytes());
|
||||
assert!(is_malformed(decrypt_streams(&info, &ragged, PASSWORD)));
|
||||
}
|
||||
let xml = agile_info(PASSWORD, 1000, HMAC_STUB, HMAC_STUB);
|
||||
let bad = |s: String| is_malformed(decrypt_streams(&with_agile_header(&s), &[0; 8], PASSWORD));
|
||||
assert!(bad(xml.replace("spinCount=\"1000\"", "spinCount=\"4000000000\"")));
|
||||
assert!(bad(xml.replace("saltValue=\"", "saltValue=\"*")));
|
||||
assert!(bad(xml.replace("keyBits=\"256\"", "keyBits=\"100\"")));
|
||||
assert!(bad(xml.replace("blockSize=\"16\"", "blockSize=\"8\"")));
|
||||
assert!(bad(xml.replace("<keyData", "<other")));
|
||||
assert!(bad("<encryption".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_ascii_passwords_hash_as_utf16le_like_msoffcrypto() {
|
||||
// "Pässwörd€" plus an emoji outside the BMP, a surrogate pair in UTF-16.
|
||||
let h = iterated_hash(Hash::Sha512, "P\u{e4}ssw\u{f6}rd\u{20ac}\u{1F511}", &salt(), 1000);
|
||||
assert_eq!(
|
||||
h,
|
||||
hex("2bca96ca0a7e8d88d9314a2eb1819a1ece94ff9ce34266ff82f891a88c4240c4c9f39c729b7c5c410c27fc8e2b7ad90d8c005eb68a603377b8a4ab858041c76a")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hmac_matches_the_rfc2104_test_vectors() {
|
||||
use crate::crypto::hmac as mac;
|
||||
let h = |v: Vec<u8>| v.iter().map(|b| format!("{b:02x}")).collect::<String>();
|
||||
// RFC 2202 test case 1 (HMAC-SHA-1) and HMAC-SHA-256 vectors.
|
||||
assert_eq!(h(mac(Hash::Sha1, &[0x0b; 20], b"Hi There")), "b617318655057264e28bc0b6fb378c8ef146be00");
|
||||
assert_eq!(h(mac(Hash::Sha256, &[0x0b; 20], b"Hi There")), "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7");
|
||||
// RFC 4231 test case 5 (HMAC-SHA-256): a key longer than the block size is hashed first.
|
||||
assert_eq!(
|
||||
h(mac(Hash::Sha256, &[0xaa; 131], b"Test Using Larger Than Block-Size Key - Hash Key First")),
|
||||
"60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54"
|
||||
);
|
||||
}
|
||||
|
||||
/// Strips the `<dataIntegrity …/>` element from an Agile descriptor.
|
||||
fn without_integrity(info: &[u8]) -> Vec<u8> {
|
||||
let xml = std::str::from_utf8(&info[8..]).unwrap().to_string();
|
||||
let start = xml.find("<dataIntegrity").unwrap();
|
||||
let end = start + xml[start..].find("/>").unwrap() + 2;
|
||||
let stripped = format!("{}{}", &xml[..start], &xml[end..]);
|
||||
with_agile_header(&stripped)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integrity_is_required_and_enforced() {
|
||||
let plain = payload(300);
|
||||
let (info, package) = encrypt_agile(&plain, PASSWORD, 1000);
|
||||
// An attacker can't just delete the integrity element to get the package verified.
|
||||
assert!(matches!(decrypt_streams(&without_integrity(&info), &package, PASSWORD),
|
||||
Err(DecryptError::Malformed(m)) if m.contains("dataIntegrity")));
|
||||
// An empty or ragged field is damage, not a pass.
|
||||
for bad in [("encryptedHmacKey", ""), ("encryptedHmacKey", "AA=="), ("encryptedHmacValue", "")] {
|
||||
let xml =
|
||||
agile_info(PASSWORD, 1000, HMAC_STUB, HMAC_STUB).replace(&format!("{}=\"{HMAC_STUB}\"", bad.0), &format!("{}=\"{}\"", bad.0, bad.1));
|
||||
let r = decrypt_streams(&with_agile_header(&xml), &package, PASSWORD);
|
||||
assert!(matches!(r, Err(DecryptError::Malformed(_))), "{}=\"{}\"", bad.0, bad.1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modified_ciphertext_fails_the_integrity_check() {
|
||||
let plain = payload(300);
|
||||
let (info, package) = encrypt_agile(&plain, PASSWORD, 1000);
|
||||
// The correct password proves the verifier can't vouch for a modified package.
|
||||
for at in [8usize, 100, package.len() - 5] {
|
||||
let mut tampered = package.clone();
|
||||
tampered[at] ^= 0x80;
|
||||
assert!(
|
||||
matches!(decrypt_streams(&info, &tampered, PASSWORD),
|
||||
Err(DecryptError::Malformed(m)) if m.contains("integrity")),
|
||||
"byte {at}"
|
||||
);
|
||||
}
|
||||
// And neither can the descriptor: a swapped integrity value is refused.
|
||||
let xml = String::from_utf8_lossy(&info[8..]).to_string();
|
||||
let at = xml.find("encryptedHmacValue=\"").unwrap() + "encryptedHmacValue=\"".len();
|
||||
let mut xml: Vec<u8> = xml.into_bytes();
|
||||
xml[at] ^= b'A' ^ b'B';
|
||||
assert!(matches!(decrypt_streams(&with_agile_header(std::str::from_utf8(&xml).unwrap()), &package, PASSWORD), Err(DecryptError::Malformed(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integrity_matches_the_stream_not_the_plaintext() {
|
||||
// The MAC covers the whole EncryptedPackage stream, StreamSize field included: changing the
|
||||
// stated size alone must fail, while the actual bytes round-trip.
|
||||
let plain = payload(4200);
|
||||
let (info, package) = encrypt_agile(&plain, PASSWORD, 1000);
|
||||
let mut ours = package.clone();
|
||||
ours[..8].copy_from_slice(&((plain.len() + 1) as u64).to_le_bytes());
|
||||
assert!(matches!(decrypt_streams(&info, &ours, PASSWORD), Err(DecryptError::Malformed(m)) if m.contains("integrity")));
|
||||
assert_eq!(decrypt_streams(&info, &package, PASSWORD).unwrap(), plain);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integrity_keys_as_long_as_the_hash_or_the_salt_verify() {
|
||||
// Excel keys the HMAC with 64 bytes (#2712); [MS-OFFCRYPTO] 2.3.4.14 describes a salt-size
|
||||
// key. Both verify, and neither lets a modified package through.
|
||||
let plain = payload(5000);
|
||||
for key in [vec![7u8; 64], vec![7u8; 16]] {
|
||||
let (info, package) = encrypt_agile_with_hmac_key(&plain, PASSWORD, 1000, &key);
|
||||
assert_eq!(decrypt_streams(&info, &package, PASSWORD).unwrap(), plain, "{}-byte key", key.len());
|
||||
let mut tampered = package.clone();
|
||||
tampered[100] ^= 1;
|
||||
assert!(matches!(decrypt_streams(&info, &tampered, PASSWORD), Err(DecryptError::Malformed(m)) if m.contains("integrity")));
|
||||
}
|
||||
// Another length (a key that matches neither) fails.
|
||||
let (info, package) = encrypt_agile_with_hmac_key(&plain, PASSWORD, 1000, &[7u8; 32]);
|
||||
assert!(matches!(decrypt_streams(&info, &package, PASSWORD), Err(DecryptError::Malformed(m)) if m.contains("integrity")));
|
||||
}
|
||||
+10
-251
@@ -1,4 +1,6 @@
|
||||
//! CSV import and export (RFC 4180 with the usual real-world leniency).
|
||||
//! CSV import and export (RFC 4180 with the usual real-world leniency). Decoding, delimiter
|
||||
//! sniffing, the record parser and field quoting are `craft-csv`'s; this module turns records into
|
||||
//! typed cells (with each column's date order) and writes a sheet.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -12,241 +14,11 @@ use crate::{CsvOptions, IoError, ReadReport};
|
||||
/// The largest CSV or tab-separated file written (bytes).
|
||||
pub const MAX_CSV_BYTES: usize = 512 << 20;
|
||||
|
||||
/// Windows-1252 code points for bytes 0x80–0x9F (others map to Latin-1).
|
||||
const CP1252: [u32; 32] = [
|
||||
0x20AC, 0x81, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x8D, 0x017D, 0x8F, 0x90, 0x2018, 0x2019,
|
||||
0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x9D, 0x017E, 0x0178,
|
||||
];
|
||||
pub use craft_csv::{decode_text, sniff_delimiter};
|
||||
|
||||
/// Windows-1256 code points for bytes 0x80–0xFF (unicode.org `CP1256.TXT`).
|
||||
pub(crate) const CP1256: [u32; 128] = [
|
||||
0x20AC, 0x067E, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0679, 0x2039, 0x0152, 0x0686, 0x0698, 0x0688, 0x06AF, 0x2018,
|
||||
0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0x06A9, 0x2122, 0x0691, 0x203A, 0x0153, 0x200C, 0x200D, 0x06BA, 0x00A0, 0x060C, 0x00A2, 0x00A3,
|
||||
0x00A4, 0x00A5, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x06BE, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x00AF, 0x00B0, 0x00B1, 0x00B2, 0x00B3, 0x00B4, 0x00B5,
|
||||
0x00B6, 0x00B7, 0x00B8, 0x00B9, 0x061B, 0x00BB, 0x00BC, 0x00BD, 0x00BE, 0x061F, 0x06C1, 0x0621, 0x0622, 0x0623, 0x0624, 0x0625, 0x0626, 0x0627,
|
||||
0x0628, 0x0629, 0x062A, 0x062B, 0x062C, 0x062D, 0x062E, 0x062F, 0x0630, 0x0631, 0x0632, 0x0633, 0x0634, 0x0635, 0x0636, 0x00D7, 0x0637, 0x0638,
|
||||
0x0639, 0x063A, 0x0640, 0x0641, 0x0642, 0x0643, 0x00E0, 0x0644, 0x00E2, 0x0645, 0x0646, 0x0647, 0x0648, 0x00E7, 0x00E8, 0x00E9, 0x00EA, 0x00EB,
|
||||
0x0649, 0x064A, 0x00EE, 0x00EF, 0x064B, 0x064C, 0x064D, 0x064E, 0x00F4, 0x064F, 0x0650, 0x00F7, 0x0651, 0x00F9, 0x0652, 0x00FB, 0x00FC, 0x200E,
|
||||
0x200F, 0x06D2,
|
||||
];
|
||||
|
||||
/// Evidence that bytes which aren't UTF-8 are Windows-1256 Arabic rather than Windows-1252.
|
||||
#[derive(Default)]
|
||||
struct Cp1256Evidence {
|
||||
/// High bytes that decode to different characters in the two code pages, apart from invisible
|
||||
/// marks and ordinals after a digit.
|
||||
differing: usize,
|
||||
/// Of those, Arabic letters and Arabic punctuation (، ؛ ؟) in Windows-1256.
|
||||
arabic: usize,
|
||||
/// Arabic bytes next to an ASCII letter: Western accents sit inside Latin words, Arabic doesn't.
|
||||
arabic_touching_latin: usize,
|
||||
/// Accented Latin bytes the two code pages share, next to another high byte: Hebrew
|
||||
/// Windows-1255 letters land there, Arabic and Western text rarely do.
|
||||
shared_letters_in_runs: usize,
|
||||
}
|
||||
|
||||
impl Cp1256Evidence {
|
||||
fn of(bytes: &[u8]) -> Self {
|
||||
let mut e = Self::default();
|
||||
for (i, &b) in bytes.iter().enumerate() {
|
||||
if b < 0x80 {
|
||||
continue;
|
||||
}
|
||||
let before = i.checked_sub(1).and_then(|j| bytes.get(j)).copied();
|
||||
let after = bytes.get(i + 1).copied();
|
||||
let next_to = |pred: fn(u8) -> bool| before.is_some_and(pred) || after.is_some_and(pred);
|
||||
if same_in_cp1252_and_cp1256(b) {
|
||||
if is_shared_latin_letter(b) && next_to(|x| x >= 0x80) {
|
||||
e.shared_letters_in_runs += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if is_invisible_mark(b) || is_ordinal_after_digit(b, before) {
|
||||
continue;
|
||||
}
|
||||
e.differing += 1;
|
||||
if is_cp1256_arabic(b) {
|
||||
e.arabic += 1;
|
||||
if next_to(|x| x.is_ascii_alphabetic()) {
|
||||
e.arabic_touching_latin += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
e
|
||||
}
|
||||
|
||||
/// Thresholds chosen against month, day and language names from 95 glibc locales (Western in
|
||||
/// Windows-1252, Arabic, Persian and Urdu in 1256, Hebrew and Yiddish in 1255) plus CSV samples.
|
||||
fn is_arabic(&self) -> bool {
|
||||
self.arabic >= 2
|
||||
&& self.arabic * 10 >= self.differing * 9
|
||||
&& self.arabic_touching_latin * 10 <= self.arabic * 3
|
||||
&& self.shared_letters_in_runs * 20 <= self.arabic
|
||||
}
|
||||
}
|
||||
|
||||
fn same_in_cp1252_and_cp1256(b: u8) -> bool {
|
||||
matches!(
|
||||
b,
|
||||
0x80 | 0x82..=0x89 | 0x8B..=0x8C | 0x91..=0x97 | 0x99 | 0x9B..=0x9C | 0xA0 | 0xA2..=0xA9 | 0xAB..=0xB9 | 0xBB..=0xBE | 0xD7 | 0xE0 | 0xE2 | 0xE7..=0xEB
|
||||
| 0xEE..=0xEF | 0xF4 | 0xF7 | 0xF9 | 0xFB..=0xFC
|
||||
)
|
||||
}
|
||||
|
||||
/// ZWNJ, ZWJ, LRM and RLM: common inside Persian words and around Arabic cells, but no evidence
|
||||
/// either way.
|
||||
fn is_invisible_mark(b: u8) -> bool {
|
||||
matches!(b, 0x9D | 0x9E | 0xFD | 0xFE)
|
||||
}
|
||||
|
||||
/// `º` and `ª` after a digit (`1º`, `2ª`) are Western ordinals, though Windows-1256 reads the bytes
|
||||
/// as `؛` and `ھ`; either way they are no evidence.
|
||||
fn is_ordinal_after_digit(b: u8, before: Option<u8>) -> bool {
|
||||
matches!(b, 0xAA | 0xBA) && before.is_some_and(|x| x.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn is_shared_latin_letter(b: u8) -> bool {
|
||||
matches!(b, 0xE0 | 0xE2 | 0xE7..=0xEB | 0xEE..=0xEF | 0xF4 | 0xF9 | 0xFB..=0xFC)
|
||||
}
|
||||
|
||||
fn is_cp1256_arabic(b: u8) -> bool {
|
||||
matches!(
|
||||
b,
|
||||
0x81 | 0x8A | 0x8D..=0x90 | 0x98 | 0x9A | 0x9F | 0xA1 | 0xAA | 0xBA | 0xBF..=0xD6 | 0xD8..=0xDB | 0xDD..=0xDF | 0xE1 | 0xE3..=0xE6 | 0xEC..=0xED | 0xFF
|
||||
)
|
||||
}
|
||||
|
||||
/// Decodes CSV bytes with BOM-aware UTF-8 and UTF-16 handling. Without a BOM, valid UTF-8 is kept.
|
||||
/// Other bytes decode as Windows-1256 when they read as Arabic (see [`Cp1256Evidence`]), else as
|
||||
/// Windows-1252.
|
||||
pub fn decode_text(bytes: &[u8]) -> String {
|
||||
if let Some(rest) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
|
||||
return String::from_utf8_lossy(rest).into_owned();
|
||||
}
|
||||
let utf16 = |b: &[u8], le: bool| -> String {
|
||||
let units = b.as_chunks::<2>().0.iter().map(|c| if le { u16::from_le_bytes(*c) } else { u16::from_be_bytes(*c) });
|
||||
char::decode_utf16(units).map(|r| r.unwrap_or('\u{FFFD}')).collect()
|
||||
};
|
||||
if let Some(rest) = bytes.strip_prefix(&[0xFF, 0xFE]) {
|
||||
return utf16(rest, true);
|
||||
}
|
||||
if let Some(rest) = bytes.strip_prefix(&[0xFE, 0xFF]) {
|
||||
return utf16(rest, false);
|
||||
}
|
||||
match std::str::from_utf8(bytes) {
|
||||
Ok(s) => s.to_string(),
|
||||
Err(_) => {
|
||||
let use_windows_1256 = Cp1256Evidence::of(bytes).is_arabic();
|
||||
bytes
|
||||
.iter()
|
||||
.map(|&b| match b {
|
||||
0x80..=0xFF if use_windows_1256 => {
|
||||
char::from_u32(CP1256.get((b - 0x80) as usize).copied().unwrap_or(0xFFFD)).unwrap_or('\u{FFFD}')
|
||||
}
|
||||
0x80..=0x9F => char::from_u32(CP1252.get((b - 0x80) as usize).copied().unwrap_or(0xFFFD)).unwrap_or('\u{FFFD}'),
|
||||
_ => b as char,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Picks comma, semicolon or tab: the candidate with the most consistent count per line
|
||||
/// (outside quotes) over the first non-blank lines. A candidate must appear on every such line, otherwise comma wins.
|
||||
pub fn sniff_delimiter(text: &str, quote: char) -> u8 {
|
||||
let mut best = (b',', 0usize, 0usize);
|
||||
for d in [',', ';', '\t'] {
|
||||
let mut counts = vec![];
|
||||
let mut n = 0usize;
|
||||
let mut in_q = false;
|
||||
let mut blank = true;
|
||||
for ch in text.chars().take(64 * 1024) {
|
||||
if !in_q && ch == '\n' {
|
||||
// Blank lines carry no delimiter evidence.
|
||||
if !blank {
|
||||
counts.push(n);
|
||||
}
|
||||
n = 0;
|
||||
blank = true;
|
||||
if counts.len() >= 20 {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ch != '\r' {
|
||||
blank = false;
|
||||
}
|
||||
if ch == quote {
|
||||
in_q = !in_q;
|
||||
} else if !in_q && ch == d {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
if !blank {
|
||||
counts.push(n);
|
||||
}
|
||||
let min = counts.iter().copied().min().unwrap_or(0);
|
||||
let total: usize = counts.iter().sum();
|
||||
if min > 0 && (min, total) > (best.1, best.2) {
|
||||
best = (d as u8, min, total);
|
||||
}
|
||||
}
|
||||
best.0
|
||||
}
|
||||
|
||||
/// Splits CSV text into records of fields. A record keeps at most [`MAX_COLS`] fields; the rest are
|
||||
/// dropped as they are read, so a row of endless delimiters cannot allocate a string per field.
|
||||
pub(crate) fn parse_records(text: &str, delim: char, quote: char, mut on_record: impl FnMut(Vec<String>) -> bool) {
|
||||
let mut field = String::new();
|
||||
let mut record: Vec<String> = vec![];
|
||||
let mut in_q = false;
|
||||
let mut chars = text.chars().peekable();
|
||||
let mut field_started_quoted = false;
|
||||
while let Some(c) = chars.next() {
|
||||
if in_q {
|
||||
if c == quote {
|
||||
if chars.peek() == Some("e) {
|
||||
field.push(quote);
|
||||
chars.next();
|
||||
} else {
|
||||
in_q = false;
|
||||
}
|
||||
} else {
|
||||
field.push(c);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if c == quote && field.is_empty() && !field_started_quoted {
|
||||
in_q = true;
|
||||
field_started_quoted = true;
|
||||
} else if c == delim {
|
||||
let f = std::mem::take(&mut field);
|
||||
if record.len() < MAX_COLS as usize {
|
||||
record.push(f);
|
||||
}
|
||||
field_started_quoted = false;
|
||||
} else if c == '\r' || c == '\n' {
|
||||
if c == '\r' && chars.peek() == Some(&'\n') {
|
||||
chars.next();
|
||||
}
|
||||
let f = std::mem::take(&mut field);
|
||||
if record.len() < MAX_COLS as usize {
|
||||
record.push(f);
|
||||
}
|
||||
field_started_quoted = false;
|
||||
if !on_record(std::mem::take(&mut record)) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
field.push(c);
|
||||
}
|
||||
}
|
||||
if !field.is_empty() || !record.is_empty() || field_started_quoted {
|
||||
if record.len() < MAX_COLS as usize {
|
||||
record.push(field);
|
||||
}
|
||||
on_record(record);
|
||||
}
|
||||
/// [`craft_csv::parse_records`] with a record kept to the grid's [`MAX_COLS`] fields.
|
||||
fn parse_records(text: &str, delim: char, quote: char, on_record: impl FnMut(Vec<String>) -> bool) {
|
||||
craft_csv::parse_records(text, delim, quote, MAX_COLS as usize, on_record);
|
||||
}
|
||||
|
||||
/// How many rows and bytes of a text file are read to decide each column's date order.
|
||||
@@ -587,25 +359,12 @@ pub fn write_csv_limited(sheet: &Sheet, delimiter: u8, limit: usize, mut cell_te
|
||||
out.push(delim);
|
||||
}
|
||||
let t = cell_text(CellRef::new(row, col));
|
||||
let quoted = t.contains(delim) || t.contains('"') || t.contains('\n') || t.contains('\r');
|
||||
// The exact size of the field as written (the delimiter was pushed above), so a row
|
||||
// never grows past the cap unseen and no doubled-quote copy of the text is built.
|
||||
let field_len = if quoted { t.len() + t.matches('"').count() + 2 } else { t.len() };
|
||||
if out.len().saturating_add(field_len) > limit {
|
||||
// never grows past the cap unseen.
|
||||
if out.len().saturating_add(craft_csv::field_len(&t, delim)) > limit {
|
||||
return Err(too_large(format!("more than {} MiB (stopped at row {})", limit >> 20, row + 1)));
|
||||
}
|
||||
if quoted {
|
||||
out.push('"');
|
||||
for ch in t.chars() {
|
||||
if ch == '"' {
|
||||
out.push('"');
|
||||
}
|
||||
out.push(ch);
|
||||
}
|
||||
out.push('"');
|
||||
} else {
|
||||
out.push_str(&t);
|
||||
}
|
||||
craft_csv::push_field(&mut out, &t, delim);
|
||||
}
|
||||
out.push_str("\r\n");
|
||||
if out.len() > limit {
|
||||
|
||||
@@ -221,7 +221,16 @@ fn read_shape(obj: &El, anchor: Anchor) -> Option<Shape> {
|
||||
let tx_box = obj.path(&["nvSpPr", "cNvSpPr"]).is_some_and(|c| c.flag("txBox", false));
|
||||
let ln = sp.child("ln");
|
||||
let arrow = ln.is_some_and(|l| l.child("tailEnd").or_else(|| l.child("headEnd")).is_some_and(|t| t.attr("type").is_some_and(|v| v != "none")));
|
||||
// WordArt: our marker, a text body with the non-warping text warp `textNoShape`, on a rectangle
|
||||
// that is a text box (as the writer saves it) or has no fill and no outline of its own (as
|
||||
// WordArt usually comes). `textNoShape` is the standard "no warp" value, so on an ordinary
|
||||
// shape (an ellipse, a filled rectangle) it, like any other warp, leaves the shape what its
|
||||
// geometry says.
|
||||
let word_art = prst == "rect"
|
||||
&& (tx_box || (sp.child("noFill").is_some() && !ln.is_some_and(|l| l.child("solidFill").is_some())))
|
||||
&& obj.path(&["txBody", "bodyPr", "prstTxWarp"]).is_some_and(|w| w.attr("prst") == Some("textNoShape"));
|
||||
let kind = match prst {
|
||||
_ if word_art => ShapeKind::WordArt,
|
||||
_ if tx_box => ShapeKind::TextBox,
|
||||
"roundRect" => ShapeKind::RoundedRectangle,
|
||||
"ellipse" => ShapeKind::Ellipse,
|
||||
@@ -294,6 +303,7 @@ fn read_text_format(body: Option<&El>) -> ShapeFormat {
|
||||
f.italic = r.flag("i", false);
|
||||
f.font = r.child("latin").and_then(|l| l.attr("typeface")).filter(|t| !t.is_empty() && !t.starts_with('+')).map(str::to_string);
|
||||
f.text_color = r.child("solidFill").and_then(dml_color);
|
||||
f.text_shadow = r.child("effectLst").and_then(|e| e.child("outerShdw")).and_then(dml_color);
|
||||
f.text_outline = r.child("ln").and_then(|l| {
|
||||
let c = l.child("solidFill").and_then(dml_color)?;
|
||||
// Width in EMU (12700 per point); Excel's default hairline is 0.75 pt.
|
||||
@@ -520,7 +530,7 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String {
|
||||
ShapeKind::Triangle => ("triangle", false),
|
||||
ShapeKind::Line | ShapeKind::Arrow => ("line", false),
|
||||
// Equations are written by `write_equation`; this arm keeps the match whole.
|
||||
ShapeKind::TextBox | ShapeKind::Equation => ("rect", true),
|
||||
ShapeKind::TextBox | ShapeKind::WordArt | ShapeKind::Equation => ("rect", true),
|
||||
ShapeKind::Icon | ShapeKind::Ink => ("rect", false),
|
||||
};
|
||||
let _ = write!(
|
||||
@@ -550,7 +560,8 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String {
|
||||
s.push_str("</a:ln>");
|
||||
}
|
||||
s.push_str("</xdr:spPr>");
|
||||
if !sh.text.is_empty() {
|
||||
// A WordArt with no text still writes its body, which carries the WordArt marker.
|
||||
if !sh.text.is_empty() || sh.kind == ShapeKind::WordArt {
|
||||
let f = &sh.format;
|
||||
let anchor = match f.anchor {
|
||||
Some(ShapeTextAnchor::Top) => " anchor=\"t\"",
|
||||
@@ -559,7 +570,15 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String {
|
||||
None if sh.kind == ShapeKind::TextBox => " anchor=\"t\"",
|
||||
None => "",
|
||||
};
|
||||
let _ = write!(s, "<xdr:txBody><a:bodyPr wrap=\"square\" rtlCol=\"0\"{anchor}/><a:lstStyle/>");
|
||||
if sh.kind == ShapeKind::WordArt {
|
||||
// Our WordArt marker: a (non-warping) text warp, which the reader maps back to WordArt.
|
||||
let _ = write!(
|
||||
s,
|
||||
"<xdr:txBody><a:bodyPr wrap=\"square\" rtlCol=\"0\"{anchor}><a:prstTxWarp prst=\"textNoShape\"><a:avLst/></a:prstTxWarp></a:bodyPr><a:lstStyle/>"
|
||||
);
|
||||
} else {
|
||||
let _ = write!(s, "<xdr:txBody><a:bodyPr wrap=\"square\" rtlCol=\"0\"{anchor}/><a:lstStyle/>");
|
||||
}
|
||||
let algn = match f.align {
|
||||
None => "",
|
||||
Some(ShapeTextAlign::Left) => " algn=\"l\"",
|
||||
@@ -614,6 +633,12 @@ fn run_props(f: &ShapeFormat, theme: &Theme) -> (String, String) {
|
||||
if let Some(c) = f.text_color.as_ref().and_then(|c| srgb(c, theme)) {
|
||||
let _ = write!(body, "<a:solidFill>{c}</a:solidFill>");
|
||||
}
|
||||
if let Some(c) = f.text_shadow.as_ref().and_then(|c| srgb(c, theme)) {
|
||||
let _ = write!(
|
||||
body,
|
||||
"<a:effectLst><a:outerShdw blurRad=\"38100\" dist=\"38100\" dir=\"2700000\" algn=\"tl\" rotWithShape=\"0\">{c}</a:outerShdw></a:effectLst>"
|
||||
);
|
||||
}
|
||||
if let Some(font) = &f.font {
|
||||
let _ = write!(body, "<a:latin typeface=\"{}\"/>", esc_attr(font));
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod array_formula;
|
||||
mod cfb;
|
||||
mod chart;
|
||||
mod chartex;
|
||||
mod controls;
|
||||
@@ -272,8 +271,8 @@ pub fn sniff(bytes: &[u8]) -> Format {
|
||||
// CFB/OLE2 compound file: a password-protected workbook when it holds an encryption
|
||||
// stream, an Excel 97-2003 workbook when it holds a workbook stream, otherwise another
|
||||
// legacy binary (.doc…).
|
||||
if bytes.starts_with(&cfb::SIGNATURE) {
|
||||
return if cfb::is_encrypted(bytes) {
|
||||
if bytes.starts_with(&craft_cfb::SIGNATURE) {
|
||||
return if crypto::is_encrypted(bytes) {
|
||||
Format::Encrypted
|
||||
} else if xls::is_xls(bytes) {
|
||||
Format::Xls
|
||||
|
||||
@@ -280,7 +280,7 @@ impl Import {
|
||||
Event::GeneralRef(r) => {
|
||||
let ch = match r.resolve_char_ref().map_err(xml_error)? {
|
||||
Some(c) => c,
|
||||
None => match r.as_ref() {
|
||||
None => match &*r {
|
||||
b"amp" => '&',
|
||||
b"lt" => '<',
|
||||
b"gt" => '>',
|
||||
|
||||
+40
-167
@@ -1,12 +1,12 @@
|
||||
//! OPC package access: reading zip parts with decompression limits, relationships and part-name
|
||||
//! resolution; writing a zip package deterministically.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Cursor, Read, Write};
|
||||
//! OPC package access through `craft-opc`: zip parts read against GridCraft's limits,
|
||||
//! relationships with GridCraft's `Rel`, part-name resolution, and a deterministic zip writer.
|
||||
//! Parts are parsed with this crate's XML reader.
|
||||
|
||||
use crate::IoError;
|
||||
use crate::xml::{self, El};
|
||||
|
||||
pub use craft_opc::{normalize, resolve, split_dir};
|
||||
|
||||
/// Largest decompressed part accepted.
|
||||
pub const MAX_PART: u64 = 512 * 1024 * 1024;
|
||||
/// Largest total decompressed size read from one package.
|
||||
@@ -14,11 +14,10 @@ pub const MAX_TOTAL: u64 = 1024 * 1024 * 1024;
|
||||
/// Most entries accepted in the zip directory.
|
||||
pub const MAX_ENTRIES: usize = 200_000;
|
||||
|
||||
const LIMITS: craft_opc::Limits = craft_opc::Limits::new(MAX_ENTRIES, MAX_PART, MAX_TOTAL);
|
||||
|
||||
pub struct Package<'a> {
|
||||
zip: zip::ZipArchive<Cursor<&'a [u8]>>,
|
||||
/// Lower-case normalized part name → zip index.
|
||||
names: HashMap<String, usize>,
|
||||
total: u64,
|
||||
opc: craft_opc::LazyPackage<'a>,
|
||||
}
|
||||
|
||||
/// A relationship.
|
||||
@@ -33,88 +32,49 @@ pub struct Rel {
|
||||
pub external: bool,
|
||||
}
|
||||
|
||||
pub fn normalize(name: &str) -> String {
|
||||
name.replace('\\', "/").trim_start_matches('/').to_ascii_lowercase()
|
||||
/// GridCraft's wording of a package error.
|
||||
fn error(e: craft_opc::Error) -> IoError {
|
||||
use craft_opc::Error as E;
|
||||
match e {
|
||||
E::Zip(m) | E::Write(m) => IoError::Zip(m),
|
||||
E::TooManyEntries { declared, .. } => IoError::TooLarge(format!("{declared} zip entries")),
|
||||
E::PartTooLarge { part, limit } => IoError::TooLarge(format!("{part} expands to more than {limit} bytes")),
|
||||
E::PackageTooLarge { .. } => IoError::TooLarge("package expands to more than 1 GB".into()),
|
||||
E::Xml { part, error } => IoError::Xml(format!("{part}: {error}")),
|
||||
e if e.is_limit() => IoError::TooLarge(e.to_string()),
|
||||
e => IoError::Zip(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Package<'a> {
|
||||
pub fn open(bytes: &'a [u8]) -> Result<Package<'a>, IoError> {
|
||||
let zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| IoError::Zip(e.to_string()))?;
|
||||
if zip.len() > MAX_ENTRIES {
|
||||
return Err(IoError::TooLarge(format!("{} zip entries", zip.len())));
|
||||
}
|
||||
let mut names = HashMap::new();
|
||||
for i in 0..zip.len() {
|
||||
if let Some(n) = zip.name_for_index(i) {
|
||||
names.entry(normalize(n)).or_insert(i);
|
||||
}
|
||||
}
|
||||
Ok(Package { zip, names, total: 0 })
|
||||
Ok(Package { opc: craft_opc::LazyPackage::open(bytes, &LIMITS).map_err(error)? })
|
||||
}
|
||||
|
||||
/// Every part's (normalized) name, sorted.
|
||||
#[cfg(test)]
|
||||
pub fn names(&self) -> Vec<String> {
|
||||
let mut v: Vec<String> = self.names.keys().cloned().collect();
|
||||
v.sort();
|
||||
v
|
||||
self.opc.names().into_iter().map(str::to_string).collect()
|
||||
}
|
||||
|
||||
pub fn has(&self, part: &str) -> bool {
|
||||
self.names.contains_key(&normalize(part))
|
||||
self.opc.has(part)
|
||||
}
|
||||
|
||||
/// Reads a part's bytes; `Ok(None)` when it doesn't exist.
|
||||
pub fn read(&mut self, part: &str) -> Result<Option<Vec<u8>>, IoError> {
|
||||
self.read_limited(part, MAX_PART)
|
||||
self.opc.read(part).map_err(error)
|
||||
}
|
||||
|
||||
/// Reads a part the reader can do without (the caller warns and goes on when it fails). A read
|
||||
/// that fails partway is charged this part's whole allowance (`maximum`, and the byte that
|
||||
/// detects an overrun) instead of ending the package's: the decompression work stays bounded,
|
||||
/// and the parts read after it still open.
|
||||
/// that fails partway is charged this part's whole allowance instead of ending the package's.
|
||||
pub fn read_optional_limited(&mut self, part: &str, maximum: u64) -> Result<Option<Vec<u8>>, IoError> {
|
||||
let before = self.total;
|
||||
let read = self.read_limited(part, maximum);
|
||||
if let Err(IoError::Zip(_)) = &read {
|
||||
self.total = before.saturating_add(maximum.min(MAX_PART).saturating_add(1)).min(MAX_TOTAL);
|
||||
}
|
||||
read
|
||||
self.opc.read_optional_limited(part, maximum).map_err(error)
|
||||
}
|
||||
|
||||
/// Reads a small metadata part without allowing it to expand to the general part limit.
|
||||
pub fn read_limited(&mut self, part: &str, maximum: u64) -> Result<Option<Vec<u8>>, IoError> {
|
||||
let maximum = maximum.min(MAX_PART);
|
||||
let Some(&idx) = self.names.get(&normalize(part)) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut f = self.zip.by_index(idx).map_err(|e| IoError::Zip(format!("{part}: {e}")))?;
|
||||
if f.size() > maximum {
|
||||
return Err(IoError::TooLarge(format!("{part} expands to {} bytes", f.size())));
|
||||
}
|
||||
let remaining = MAX_TOTAL.saturating_sub(self.total);
|
||||
let limit = maximum.min(remaining);
|
||||
let mut out = Vec::with_capacity(f.size().min(16 * 1024 * 1024) as usize);
|
||||
let read = (&mut f).take(limit + 1).read_to_end(&mut out).map_err(|e| {
|
||||
// A read that fails partway has already spent decompression work and buffered data
|
||||
// the package budget never sees: count the allowance as spent, so a loop over many
|
||||
// broken parts can't decompress it over and over.
|
||||
self.total = MAX_TOTAL;
|
||||
IoError::Zip(format!("{part}: {e}"))
|
||||
});
|
||||
if out.len() as u64 > limit {
|
||||
// The allowance was spent getting here; make the exhaustion terminal instead of
|
||||
// letting later reads pay the same decompression cost again.
|
||||
self.total = MAX_TOTAL;
|
||||
return Err(IoError::TooLarge(if remaining < maximum {
|
||||
"package expands to more than 1 GB".into()
|
||||
} else {
|
||||
format!("{part} expands to more than {maximum} bytes")
|
||||
}));
|
||||
}
|
||||
read?;
|
||||
self.total += out.len() as u64;
|
||||
Ok(Some(out))
|
||||
self.opc.read_limited(part, maximum).map_err(error)
|
||||
}
|
||||
|
||||
pub fn read_xml(&mut self, part: &str) -> Result<Option<El>, IoError> {
|
||||
@@ -127,25 +87,13 @@ impl<'a> Package<'a> {
|
||||
/// Relationships of a part (`xl/workbook.xml` → `xl/_rels/workbook.xml.rels`). Missing or
|
||||
/// broken rels parts give an empty list.
|
||||
pub fn rels(&mut self, part: &str) -> Result<Vec<Rel>, IoError> {
|
||||
let (dir, file) = split_dir(part);
|
||||
let rels_name = if dir.is_empty() { format!("_rels/{file}.rels") } else { format!("{dir}/_rels/{file}.rels") };
|
||||
let root = match self.read(&rels_name)? {
|
||||
Some(b) => match xml::parse(&b) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return Ok(vec![]),
|
||||
},
|
||||
None => return Ok(vec![]),
|
||||
};
|
||||
Ok(root
|
||||
.kids("Relationship")
|
||||
.filter_map(|r| {
|
||||
let id = r.attr("Id")?.to_string();
|
||||
let ty = r.attr("Type").unwrap_or("");
|
||||
let kind = ty.rsplit('/').next().unwrap_or("").to_string();
|
||||
let target_raw = r.attr("Target")?;
|
||||
let external = r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External"));
|
||||
let target = if external { target_raw.to_string() } else { resolve(dir, target_raw) };
|
||||
Some(Rel { id, kind, target, external })
|
||||
let rels = self.opc.rels(part).map_err(error)?;
|
||||
Ok(rels
|
||||
.list
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let kind = r.kind().to_string();
|
||||
Rel { id: r.id, kind, target: r.target, external: r.external }
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -153,11 +101,7 @@ impl<'a> Package<'a> {
|
||||
|
||||
/// The first part whose declared unpacked size is over [`MAX_PART`], with that size.
|
||||
pub fn oversized_part(bytes: &[u8]) -> Option<(String, u64)> {
|
||||
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).ok()?;
|
||||
(0..zip.len().min(MAX_ENTRIES)).find_map(|i| {
|
||||
let f = zip.by_index_raw(i).ok()?;
|
||||
(f.size() > MAX_PART).then(|| (f.name().to_string(), f.size()))
|
||||
})
|
||||
craft_opc::declared_oversized(bytes, &LIMITS)
|
||||
}
|
||||
|
||||
fn prefix(part: &str, e: IoError) -> IoError {
|
||||
@@ -167,54 +111,22 @@ fn prefix(part: &str, e: IoError) -> IoError {
|
||||
}
|
||||
}
|
||||
|
||||
/// `xl/worksheets/sheet1.xml` → (`xl/worksheets`, `sheet1.xml`).
|
||||
pub fn split_dir(part: &str) -> (&str, &str) {
|
||||
let p = part.trim_start_matches('/');
|
||||
match p.rfind('/') {
|
||||
Some(i) => (p.get(..i).unwrap_or(""), p.get(i + 1..).unwrap_or("")),
|
||||
None => ("", p),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a relationship target against the source part's directory.
|
||||
pub fn resolve(dir: &str, target: &str) -> String {
|
||||
let target = target.replace('\\', "/");
|
||||
let target = target.split('#').next().unwrap_or("").to_string();
|
||||
let mut segs: Vec<&str> = if target.starts_with('/') { vec![] } else { dir.split('/').filter(|s| !s.is_empty()).collect() };
|
||||
for s in target.split('/') {
|
||||
match s {
|
||||
"" | "." => {}
|
||||
".." => {
|
||||
segs.pop();
|
||||
}
|
||||
s => segs.push(s),
|
||||
}
|
||||
}
|
||||
segs.join("/")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- writing
|
||||
|
||||
/// A zip package being written: every entry deflated, with one fixed timestamp.
|
||||
pub struct ZipOut {
|
||||
zip: zip::ZipWriter<Cursor<Vec<u8>>>,
|
||||
zip: craft_opc::ZipOut,
|
||||
}
|
||||
|
||||
impl ZipOut {
|
||||
pub fn new() -> ZipOut {
|
||||
ZipOut { zip: zip::ZipWriter::new(Cursor::new(Vec::new())) }
|
||||
ZipOut { zip: craft_opc::ZipOut::new() }
|
||||
}
|
||||
pub fn add(&mut self, name: &str, data: &[u8]) -> Result<(), IoError> {
|
||||
let opts = zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated)
|
||||
.compression_level(Some(6))
|
||||
.last_modified_time(zip::DateTime::default())
|
||||
.large_file(data.len() as u64 >= u32::MAX as u64);
|
||||
self.zip.start_file(name, opts).map_err(|e| IoError::Zip(e.to_string()))?;
|
||||
self.zip.write_all(data).map_err(|e| IoError::Zip(e.to_string()))?;
|
||||
Ok(())
|
||||
self.zip.add(name, data, craft_opc::Compression::Deflate).map_err(error)
|
||||
}
|
||||
pub fn finish(self) -> Result<Vec<u8>, IoError> {
|
||||
self.zip.finish().map(|c| c.into_inner()).map_err(|e| IoError::Zip(e.to_string()))
|
||||
self.zip.finish().map_err(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,43 +144,4 @@ mod tests {
|
||||
assert_eq!(split_dir("xl/workbook.xml"), ("xl", "workbook.xml"));
|
||||
assert_eq!(split_dir("x.xml"), ("", "x.xml"));
|
||||
}
|
||||
|
||||
/// An over-budget read must spend the package's allowance once and for all: loops over many
|
||||
/// broken parts (a sheet's optional VML relationships, for one) otherwise re-decompress the
|
||||
/// remaining allowance again and again.
|
||||
#[test]
|
||||
fn a_failed_part_ends_the_package_budget() {
|
||||
let z = crate::tests::make_zip(&[("a.xml", b"0123456789"), ("b.xml", b"hello")]);
|
||||
let mut pkg = Package::open(&z).unwrap();
|
||||
assert!(pkg.read("a.xml").unwrap().is_some());
|
||||
// Nothing left to spend within the per-part limit either.
|
||||
pkg.total = MAX_TOTAL - 1;
|
||||
assert!(pkg.read("b.xml").is_err());
|
||||
assert_eq!(pkg.total, MAX_TOTAL, "the failed read is charged");
|
||||
// Oversized repeated attempts stay cheap errors now: no allowance is left to pay for.
|
||||
assert!(matches!(pkg.read("b.xml"), Err(IoError::TooLarge(_))));
|
||||
}
|
||||
|
||||
/// A part the reader can do without, damaged inside an intact archive, costs its own
|
||||
/// allowance: the parts after it are still read.
|
||||
#[test]
|
||||
fn a_damaged_optional_part_costs_only_its_allowance() {
|
||||
use std::io::Write as _;
|
||||
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
w.start_file("a.xml", stored).unwrap();
|
||||
w.write_all(b"<a>damaged</a>").unwrap();
|
||||
w.start_file("b.xml", zip::write::SimpleFileOptions::default()).unwrap();
|
||||
w.write_all(b"hello").unwrap();
|
||||
let mut z = w.finish().unwrap().into_inner();
|
||||
let at = z.windows(7).position(|x| x == b"damaged").unwrap();
|
||||
z[at] ^= 0x20;
|
||||
let mut pkg = Package::open(&z).unwrap();
|
||||
assert!(matches!(pkg.read_optional_limited("a.xml", 1024), Err(IoError::Zip(_))));
|
||||
assert_eq!(pkg.total, 1025, "the failed read is charged its allowance");
|
||||
assert_eq!(pkg.read("b.xml").unwrap().as_deref(), Some(&b"hello"[..]));
|
||||
// An ordinary read of it still ends the package's budget.
|
||||
assert!(pkg.read("a.xml").is_err());
|
||||
assert_eq!(pkg.total, MAX_TOTAL);
|
||||
}
|
||||
}
|
||||
|
||||
+6
-263
@@ -1,282 +1,25 @@
|
||||
//! Test support: builds password-encrypted workbooks in code, so tests here and in the crates
|
||||
//! above (engine, UI) never need an encrypted file on disk. Compiled for this crate's tests and,
|
||||
//! elsewhere, only with the `test-support` feature (a dev-dependency feature, never shipped).
|
||||
//!
|
||||
//! Salts and keys are fixed so the output is deterministic. This is not an encryptor for real
|
||||
//! use: a fixed key protects nothing.
|
||||
//! The encryption is `craft-offcrypto`'s and the compound file `craft-cfb`'s.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use aes::cipher::{BlockCipherEncrypt, KeyInit};
|
||||
|
||||
use crate::crypto::{
|
||||
BLOCK_HMAC_KEY, BLOCK_HMAC_VALUE, BLOCK_KEY, BLOCK_VERIFIER_HASH, BLOCK_VERIFIER_INPUT, F_AES, F_CRYPTOAPI, Hash, PASSWORD_ENCRYPTOR, Params,
|
||||
SEGMENT, derive, hmac, iterated_hash, standard_key,
|
||||
};
|
||||
|
||||
/// The spin count `encrypt_workbook` uses: Excel's default.
|
||||
pub const SPIN_COUNT: u32 = 100_000;
|
||||
|
||||
const PASSWORD_SALT: [u8; 16] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];
|
||||
const KEY_DATA_SALT: [u8; 16] = [16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31];
|
||||
const SECRET_KEY: [u8; 32] =
|
||||
[64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95];
|
||||
const VERIFIER: [u8; 16] = [32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47];
|
||||
/// The dataIntegrity HMAC key, as long as the hash: Excel writes 64 bytes for SHA-512, not the
|
||||
/// salt-size key [MS-OFFCRYPTO] 2.3.4.14 describes.
|
||||
const HMAC_KEY: [u8; 64] = [
|
||||
112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140,
|
||||
141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169,
|
||||
170, 171, 172, 173, 174, 175,
|
||||
];
|
||||
|
||||
/// `package` (normally an `.xlsx` zip) as Excel stores it with a password: Agile encryption
|
||||
/// (AES-256, SHA-512) inside a compound file.
|
||||
pub fn encrypt_workbook(package: &[u8], password: &str) -> Vec<u8> {
|
||||
let (info, encrypted) = encrypt_agile(package, password, SPIN_COUNT);
|
||||
compound_file(&[("EncryptionInfo", &info), ("EncryptedPackage", &encrypted)])
|
||||
craft_offcrypto::encrypt_with_spin_count(package, password, SPIN_COUNT).unwrap()
|
||||
}
|
||||
|
||||
/// The same with Standard encryption (AES-128, SHA-1), as Excel 2007 wrote it.
|
||||
pub fn encrypt_workbook_standard(package: &[u8], password: &str) -> Vec<u8> {
|
||||
let (info, encrypted) = encrypt_standard(package, password);
|
||||
compound_file(&[("EncryptionInfo", &info), ("EncryptedPackage", &encrypted)])
|
||||
craft_offcrypto::encrypt_standard(package, password, 128).unwrap()
|
||||
}
|
||||
|
||||
/// A version 3 compound file (512-byte sectors) holding `streams` under the root, laid out as
|
||||
/// Excel lays it out: streams under 4096 bytes in the mini stream, larger ones in regular
|
||||
/// sectors. Up to 109 FAT sectors (about 7 MB of streams).
|
||||
/// A version 3 compound file (512-byte sectors) holding `streams` under the root: streams under
|
||||
/// 4096 bytes in the mini stream, larger ones in regular sectors.
|
||||
pub fn compound_file(streams: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
const SECTOR: usize = 512;
|
||||
const MINI: usize = 64;
|
||||
const CUTOFF: usize = 4096;
|
||||
const FREE: u32 = 0xFFFF_FFFF;
|
||||
const END: u32 = 0xFFFF_FFFE;
|
||||
const FAT_SECTOR: u32 = 0xFFFF_FFFD;
|
||||
let is_mini = |d: &[u8]| d.len() < CUTOFF;
|
||||
|
||||
// The mini stream: small streams in 64-byte mini sectors, chained by the mini FAT.
|
||||
let mut container = vec![];
|
||||
let mut mini_fat: Vec<u32> = vec![];
|
||||
let mut mini_starts = vec![];
|
||||
for (_, d) in streams {
|
||||
if !is_mini(d) || d.is_empty() {
|
||||
mini_starts.push(END);
|
||||
continue;
|
||||
}
|
||||
let first = mini_fat.len();
|
||||
let n = d.len().div_ceil(MINI);
|
||||
for k in 0..n {
|
||||
mini_fat.push(if k + 1 == n { END } else { (first + k + 1) as u32 });
|
||||
}
|
||||
mini_starts.push(first as u32);
|
||||
container.extend_from_slice(d);
|
||||
container.resize((first + n) * MINI, 0);
|
||||
}
|
||||
|
||||
// Regular-sector blobs, in order: directory, mini FAT, mini stream, then each large stream.
|
||||
let dir_len = (streams.len() + 1).div_ceil(4) * SECTOR;
|
||||
let mut blobs: Vec<Vec<u8>> = vec![vec![0; dir_len], mini_fat.iter().flat_map(|v| v.to_le_bytes()).collect(), container.clone()];
|
||||
blobs.extend(streams.iter().map(|(_, d)| if is_mini(d) { vec![] } else { d.to_vec() }));
|
||||
let counts: Vec<usize> = blobs.iter().map(|b| b.len().div_ceil(SECTOR)).collect();
|
||||
let body: usize = counts.iter().sum();
|
||||
let mut fat_sectors = 1;
|
||||
while fat_sectors * 128 < fat_sectors + body {
|
||||
fat_sectors += 1;
|
||||
}
|
||||
assert!(fat_sectors <= 109, "the test writer has no DIFAT sectors");
|
||||
let mut fat = vec![FREE; fat_sectors * 128];
|
||||
fat[..fat_sectors].fill(FAT_SECTOR);
|
||||
let mut starts = vec![];
|
||||
let mut next = fat_sectors;
|
||||
for &n in &counts {
|
||||
if n == 0 {
|
||||
starts.push(END);
|
||||
continue;
|
||||
}
|
||||
starts.push(next as u32);
|
||||
for k in 0..n {
|
||||
fat[next + k] = if k + 1 == n { END } else { (next + k + 1) as u32 };
|
||||
}
|
||||
next += n;
|
||||
}
|
||||
|
||||
let put16 = |b: &mut [u8], at: usize, v: u16| b[at..at + 2].copy_from_slice(&v.to_le_bytes());
|
||||
let put32 = |b: &mut [u8], at: usize, v: u32| b[at..at + 4].copy_from_slice(&v.to_le_bytes());
|
||||
// Directory: the root (owning the mini stream), then the streams chained as right siblings.
|
||||
let dir = &mut blobs[0];
|
||||
for k in 0..dir_len / 128 {
|
||||
for at in [68, 72, 76] {
|
||||
put32(dir, k * 128 + at, FREE);
|
||||
}
|
||||
}
|
||||
let mut entry = |k: usize, name: &str, kind: u8, start: u32, size: usize| {
|
||||
let e = k * 128;
|
||||
let units: Vec<u8> = name.encode_utf16().flat_map(u16::to_le_bytes).collect();
|
||||
dir[e..e + units.len()].copy_from_slice(&units);
|
||||
put16(dir, e + 64, units.len() as u16 + 2);
|
||||
dir[e + 66] = kind;
|
||||
dir[e + 67] = 1; // black
|
||||
if kind == 5 && !streams.is_empty() {
|
||||
put32(dir, e + 76, 1);
|
||||
}
|
||||
if kind == 2 && k < streams.len() {
|
||||
put32(dir, e + 72, k as u32 + 1);
|
||||
}
|
||||
put32(dir, e + 116, start);
|
||||
put32(dir, e + 120, size as u32);
|
||||
};
|
||||
entry(0, "Root Entry", 5, starts[2], container.len());
|
||||
for (k, (name, data)) in streams.iter().enumerate() {
|
||||
let start = if is_mini(data) { mini_starts[k] } else { starts[3 + k] };
|
||||
entry(k + 1, name, 2, start, data.len());
|
||||
}
|
||||
|
||||
let mut out = vec![0u8; SECTOR * (1 + fat_sectors + body)];
|
||||
out[..8].copy_from_slice(&crate::cfb::SIGNATURE);
|
||||
put16(&mut out, 24, 0x3E); // minor version
|
||||
put16(&mut out, 26, 3); // major version
|
||||
put16(&mut out, 28, 0xFFFE); // byte order
|
||||
put16(&mut out, 30, 9); // 512-byte sectors
|
||||
put16(&mut out, 32, 6); // 64-byte mini sectors
|
||||
put32(&mut out, 44, fat_sectors as u32);
|
||||
put32(&mut out, 48, starts[0]);
|
||||
put32(&mut out, 56, CUTOFF as u32);
|
||||
put32(&mut out, 60, starts[1]); // first mini FAT sector (END when there is none)
|
||||
put32(&mut out, 64, counts[1] as u32);
|
||||
put32(&mut out, 68, END); // no DIFAT sectors
|
||||
for i in 0..109 {
|
||||
put32(&mut out, 76 + i * 4, if i < fat_sectors { i as u32 } else { FREE });
|
||||
}
|
||||
for (i, v) in fat.iter().enumerate() {
|
||||
put32(&mut out, SECTOR + i * 4, *v);
|
||||
}
|
||||
for (blob, start) in blobs.iter().zip(&starts) {
|
||||
if !blob.is_empty() {
|
||||
let at = SECTOR * (1 + *start as usize);
|
||||
out[at..at + blob.len()].copy_from_slice(blob);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn encrypt_block(key: &[u8], block: &mut [u8; 16]) {
|
||||
let b = aes::Block::cast_from_core_mut(block);
|
||||
match key.len() {
|
||||
16 => aes::Aes128::new_from_slice(key).unwrap().encrypt_block(b),
|
||||
32 => aes::Aes256::new_from_slice(key).unwrap().encrypt_block(b),
|
||||
n => panic!("no {n}-byte AES in the test encryptor"),
|
||||
}
|
||||
}
|
||||
|
||||
fn pad(data: &[u8]) -> Vec<u8> {
|
||||
let mut v = data.to_vec();
|
||||
v.resize(data.len().div_ceil(16) * 16, 0);
|
||||
v
|
||||
}
|
||||
|
||||
fn encrypt_ecb(key: &[u8], data: &[u8]) -> Vec<u8> {
|
||||
let mut v = pad(data);
|
||||
v.as_chunks_mut::<16>().0.iter_mut().for_each(|b| encrypt_block(key, b));
|
||||
v
|
||||
}
|
||||
|
||||
fn encrypt_cbc(key: &[u8], iv: &[u8], data: &[u8]) -> Vec<u8> {
|
||||
let mut v = pad(data);
|
||||
let mut prev: [u8; 16] = iv.try_into().unwrap();
|
||||
for b in v.as_chunks_mut::<16>().0 {
|
||||
b.iter_mut().zip(prev).for_each(|(x, p)| *x ^= p);
|
||||
encrypt_block(key, b);
|
||||
prev = *b;
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn base64_encode(b: &[u8]) -> String {
|
||||
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut s = String::new();
|
||||
for c in b.chunks(3) {
|
||||
let n = (u32::from(c[0]) << 16) | (u32::from(*c.get(1).unwrap_or(&0)) << 8) | u32::from(*c.get(2).unwrap_or(&0));
|
||||
for i in 0..4 {
|
||||
s.push(if i <= c.len() { T[(n >> (18 - 6 * i)) as usize & 63] as char } else { '=' });
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn package(size: usize, body: Vec<u8>) -> Vec<u8> {
|
||||
let mut p = (size as u64).to_le_bytes().to_vec();
|
||||
p.extend(body);
|
||||
p
|
||||
}
|
||||
|
||||
/// The Agile XML descriptor: AES-256, SHA-512, a password encryptor salted with 0..16, and the
|
||||
/// dataIntegrity pair (`hmac_key_b64`/`hmac_value_b64`) a compliant file carries.
|
||||
pub(crate) fn agile_info(password: &str, spin: u32, hmac_key_b64: &str, hmac_value_b64: &str) -> String {
|
||||
let key = Params { hash: Hash::Sha512, salt: PASSWORD_SALT.to_vec(), key_len: 32 };
|
||||
let h = iterated_hash(Hash::Sha512, password, &key.salt, spin);
|
||||
let iv = key.iv(None);
|
||||
let field = |block_key: &[u8], data: &[u8]| base64_encode(&encrypt_cbc(&derive(Hash::Sha512, &h, block_key, 32), &iv, data));
|
||||
let input = field(&BLOCK_VERIFIER_INPUT, &VERIFIER);
|
||||
let value = field(&BLOCK_VERIFIER_HASH, &Hash::Sha512.digest(&[&VERIFIER]));
|
||||
let secret = field(&BLOCK_KEY, &SECRET_KEY);
|
||||
let params = |salt: &[u8]| {
|
||||
format!(
|
||||
"saltSize=\"16\" blockSize=\"16\" keyBits=\"256\" hashSize=\"64\" cipherAlgorithm=\"AES\" cipherChaining=\"ChainingModeCBC\" hashAlgorithm=\"SHA512\" saltValue=\"{}\"",
|
||||
base64_encode(salt)
|
||||
)
|
||||
};
|
||||
// The dataIntegrity pair ([MS-OFFCRYPTO] 2.3.4.14), encrypted with the package key.
|
||||
format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\r\n<encryption xmlns=\"http://schemas.microsoft.com/office/2006/encryption\" xmlns:p=\"http://schemas.microsoft.com/office/2006/keyEncryptor/password\"><keyData {}/><dataIntegrity encryptedHmacKey=\"{hmac_key_b64}\" encryptedHmacValue=\"{hmac_value_b64}\"/><keyEncryptors><keyEncryptor uri=\"{PASSWORD_ENCRYPTOR}\"><p:encryptedKey spinCount=\"{spin}\" {} encryptedVerifierHashInput=\"{input}\" encryptedVerifierHashValue=\"{value}\" encryptedKeyValue=\"{secret}\"/></keyEncryptor></keyEncryptors></encryption>",
|
||||
params(KEY_DATA_SALT.as_slice()),
|
||||
params(&PASSWORD_SALT),
|
||||
)
|
||||
}
|
||||
|
||||
/// `EncryptionInfo` for an Agile descriptor: version 4.4, reserved 0x40, then the XML.
|
||||
pub(crate) fn with_agile_header(xml: &str) -> Vec<u8> {
|
||||
let mut info = vec![4, 0, 4, 0, 0x40, 0, 0, 0];
|
||||
info.extend_from_slice(xml.as_bytes());
|
||||
info
|
||||
}
|
||||
|
||||
/// `EncryptionInfo` and `EncryptedPackage` for `plain`, Agile (AES-256, SHA-512), with the
|
||||
/// dataIntegrity HMAC over the whole EncryptedPackage stream as Excel writes it.
|
||||
pub(crate) fn encrypt_agile(plain: &[u8], password: &str, spin: u32) -> (Vec<u8>, Vec<u8>) {
|
||||
encrypt_agile_with_hmac_key(plain, password, spin, &HMAC_KEY)
|
||||
}
|
||||
|
||||
/// [`encrypt_agile`] with the dataIntegrity HMAC keyed by `hmac_key`.
|
||||
pub(crate) fn encrypt_agile_with_hmac_key(plain: &[u8], password: &str, spin: u32, hmac_key: &[u8]) -> (Vec<u8>, Vec<u8>) {
|
||||
let key_data = Params { hash: Hash::Sha512, salt: KEY_DATA_SALT.to_vec(), key_len: 32 };
|
||||
let mut body = vec![];
|
||||
for (i, segment) in plain.chunks(SEGMENT).enumerate() {
|
||||
body.extend(encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&(i as u32).to_le_bytes())), segment));
|
||||
}
|
||||
let stream = package(plain.len(), body);
|
||||
let (hmac_key_b64, hmac_value_b64) = (
|
||||
base64_encode(&encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&BLOCK_HMAC_KEY)), hmac_key)),
|
||||
base64_encode(&encrypt_cbc(&SECRET_KEY, &key_data.iv(Some(&BLOCK_HMAC_VALUE)), &hmac(Hash::Sha512, hmac_key, &stream))),
|
||||
);
|
||||
(with_agile_header(&agile_info(password, spin, &hmac_key_b64, &hmac_value_b64)), stream)
|
||||
}
|
||||
|
||||
/// `EncryptionInfo` and `EncryptedPackage` for `plain`, Standard (AES-128, SHA-1).
|
||||
pub(crate) fn encrypt_standard(plain: &[u8], password: &str) -> (Vec<u8>, Vec<u8>) {
|
||||
let key = standard_key(password, &PASSWORD_SALT, 16);
|
||||
let mut header = vec![];
|
||||
for v in [F_CRYPTOAPI | F_AES, 0, 0x660E, 0x8004, 128, 0x18, 0, 0] {
|
||||
header.extend_from_slice(&u32::to_le_bytes(v));
|
||||
}
|
||||
header.extend("\0".encode_utf16().flat_map(u16::to_le_bytes));
|
||||
let mut info = vec![4, 0, 2, 0];
|
||||
info.extend_from_slice(&(F_CRYPTOAPI | F_AES).to_le_bytes());
|
||||
info.extend_from_slice(&(header.len() as u32).to_le_bytes());
|
||||
info.extend(header);
|
||||
info.extend_from_slice(&16u32.to_le_bytes());
|
||||
info.extend(PASSWORD_SALT);
|
||||
info.extend(encrypt_ecb(&key, &VERIFIER));
|
||||
info.extend_from_slice(&20u32.to_le_bytes());
|
||||
info.extend(encrypt_ecb(&key, &Hash::Sha1.digest(&[&VERIFIER])));
|
||||
(info, package(plain.len(), encrypt_ecb(&key, plain)))
|
||||
craft_cfb::write_streams(craft_cfb::Version::V3, streams).unwrap()
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ mod sheet_data_pin;
|
||||
mod sheet_names;
|
||||
mod smooth_roundtrip;
|
||||
mod trendline_roundtrip;
|
||||
mod xls_dv_tests;
|
||||
mod xls_style_tests;
|
||||
mod xls_tests;
|
||||
mod xlsb_tests;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use gridcraft_core::{CellRef, DateSystem, MAX_COLS, Value};
|
||||
use gridcraft_model::{Sheet, Workbook};
|
||||
|
||||
use crate::csv::{decode_text, parse_records, sniff_delimiter};
|
||||
use crate::{CsvOptions, Format, IoError, read_csv, read_csv_with_report, sniff, write_csv, write_csv_limited};
|
||||
use craft_csv::{decode_text, parse_records, sniff_delimiter};
|
||||
|
||||
fn v(wb: &Workbook, r: u32, c: u32) -> Value {
|
||||
wb.sheet(0).unwrap().value(CellRef::new(r, c))
|
||||
@@ -148,33 +148,6 @@ fn encodings() {
|
||||
assert_eq!(v(&wb, 0, 1), Value::Number(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_1256_table_matches_the_unicode_mapping() {
|
||||
let at = |b: u8| crate::csv::CP1256.get(usize::from(b - 0x80)).and_then(|&c| char::from_u32(c));
|
||||
let expected = [
|
||||
(0x80, '€'),
|
||||
(0x81, 'پ'),
|
||||
(0x8D, 'چ'),
|
||||
(0x98, 'ک'),
|
||||
(0x9D, '\u{200C}'),
|
||||
(0xA1, '،'),
|
||||
(0xBA, '؛'),
|
||||
(0xBF, '؟'),
|
||||
(0xC7, 'ا'),
|
||||
(0xC8, 'ب'),
|
||||
(0xDC, 'ـ'),
|
||||
(0xE1, 'ل'),
|
||||
(0xE3, 'م'),
|
||||
(0xE9, 'é'),
|
||||
(0xFD, '\u{200E}'),
|
||||
(0xFE, '\u{200F}'),
|
||||
(0xFF, 'ے'),
|
||||
];
|
||||
for (b, c) in expected {
|
||||
assert_eq!(at(b), Some(c), "0x{b:02X}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_csv_in_windows_1256_reads_as_arabic() {
|
||||
let bytes = [
|
||||
@@ -302,8 +275,9 @@ fn sniffing_formats() {
|
||||
assert_eq!(sniff(b"a,b\n1,2"), Format::Csv);
|
||||
// An Excel password-protected file is a CFB/OLE2 compound file with an EncryptedPackage
|
||||
// stream; any other compound file is a legacy binary (.xls), not an encrypted workbook.
|
||||
assert_eq!(sniff(&crate::cfb::tests::build(&["EncryptionInfo", "EncryptedPackage"])), Format::Encrypted);
|
||||
assert_eq!(sniff(&crate::cfb::tests::build(&["Workbook"])), Format::LegacyBinary);
|
||||
let compound = crate::testing::compound_file;
|
||||
assert_eq!(sniff(&compound(&[("EncryptionInfo", b"x"), ("EncryptedPackage", b"x")])), Format::Encrypted);
|
||||
assert_eq!(sniff(&compound(&[("WordDocument", b"x")])), Format::LegacyBinary);
|
||||
assert_eq!(sniff(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1, 0, 0]), Format::LegacyBinary);
|
||||
assert_eq!(sniff(&[0xD0, 0xCF, 0x11, 0xE0, 0, 0, 1, 2]), Format::Unknown);
|
||||
assert_eq!(sniff(b""), Format::Unknown);
|
||||
@@ -393,14 +367,14 @@ fn parser_stops_collecting_fields_at_max_cols() {
|
||||
let mut text = ",".repeat((MAX_COLS + extra) as usize - 1);
|
||||
text.push_str("end\nx,y\n");
|
||||
let mut lens = vec![];
|
||||
parse_records(&text, ',', '"', |rec| {
|
||||
parse_records(&text, ',', '"', MAX_COLS as usize, |rec| {
|
||||
lens.push(rec.len());
|
||||
true
|
||||
});
|
||||
assert_eq!(lens, vec![MAX_COLS as usize, 2]);
|
||||
// An unterminated last row is capped the same way.
|
||||
let mut lens = vec![];
|
||||
parse_records(&",".repeat(MAX_COLS as usize + extra as usize), ',', '"', |rec| {
|
||||
parse_records(&",".repeat(MAX_COLS as usize + extra as usize), ',', '"', MAX_COLS as usize, |rec| {
|
||||
lens.push(rec.len());
|
||||
true
|
||||
});
|
||||
|
||||
@@ -3,24 +3,11 @@
|
||||
use crate::testing::{compound_file, encrypt_workbook, encrypt_workbook_standard};
|
||||
use crate::{DecryptError, decrypt};
|
||||
|
||||
#[test]
|
||||
fn the_test_compound_writer_reads_back() {
|
||||
let big: Vec<u8> = (0..3_000_000u32).map(|i| (i % 253) as u8).collect();
|
||||
let mid: Vec<u8> = (0..4095u32).map(|i| i as u8).collect();
|
||||
let file = compound_file(&[("Small", b"hello"), ("Big", &big), ("Empty", b""), ("Mid", &mid)]);
|
||||
let cf = crate::cfb::Compound::open(&file).unwrap();
|
||||
assert_eq!(cf.root_stream("Mid").unwrap().unwrap(), mid, "just under the cutoff: the mini stream");
|
||||
assert_eq!(cf.root_stream("Small").unwrap().unwrap(), b"hello");
|
||||
assert_eq!(cf.root_stream("big").unwrap().unwrap(), big);
|
||||
assert_eq!(cf.root_stream("Empty").unwrap().unwrap(), b"");
|
||||
assert_eq!(cf.root_stream("Missing").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_files_decrypt_to_the_package() {
|
||||
let package: Vec<u8> = (0..3_000_000u32).map(|i| (i * 31 % 251) as u8).collect();
|
||||
let agile = encrypt_workbook(&package, "Pass1234");
|
||||
assert!(crate::cfb::is_encrypted(&agile));
|
||||
assert!(crate::crypto::is_encrypted(&agile));
|
||||
assert_eq!(decrypt(&agile, "Pass1234").unwrap(), package);
|
||||
assert_eq!(decrypt(&agile, "wrong"), Err(DecryptError::WrongPassword));
|
||||
let standard = encrypt_workbook_standard(&package, "Pass1234");
|
||||
@@ -49,16 +36,19 @@ fn a_gridcraft_workbook_survives_encryption() {
|
||||
#[test]
|
||||
fn schemes_no_password_opens_are_named_before_asking_for_one() {
|
||||
use crate::check_encryption;
|
||||
use crate::testing::{agile_info, with_agile_header};
|
||||
let unsupported = |s: &str| Err(DecryptError::Unsupported(s.into()));
|
||||
assert_eq!(check_encryption(&encrypt_workbook(b"package", "pw")), Ok(()));
|
||||
assert_eq!(check_encryption(&encrypt_workbook_standard(b"package", "pw")), Ok(()));
|
||||
let file = |info: &[u8]| compound_file(&[("EncryptionInfo", info), ("EncryptedPackage", &[0; 8])]);
|
||||
assert_eq!(check_encryption(&file(&[3, 0, 2, 0, 4, 0, 0, 0])), unsupported("RC4"));
|
||||
assert_eq!(check_encryption(&file(&[4, 0, 3, 0, 0, 0, 0, 0])), unsupported("Extensible"));
|
||||
let cert =
|
||||
agile_info("pw", 1000, "AAAAAAAAAAAAAAAAAAAAAA==", "AAAAAAAAAAAAAAAAAAAAAA==").replace("keyEncryptor/password", "keyEncryptor/certificate");
|
||||
assert_eq!(check_encryption(&file(&with_agile_header(&cert))), unsupported("certificate"));
|
||||
// An Agile descriptor whose only key encryptor is a certificate.
|
||||
let agile = encrypt_workbook(b"package", "pw");
|
||||
let cf = craft_cfb::CompoundFile::open(&agile).unwrap();
|
||||
let info = cf.read(cf.child(0, "EncryptionInfo").unwrap()).unwrap();
|
||||
let mut cert = info[..8].to_vec();
|
||||
cert.extend(String::from_utf8(info[8..].to_vec()).unwrap().replace("keyEncryptor/password", "keyEncryptor/certificate").into_bytes());
|
||||
assert_eq!(check_encryption(&file(&cert)), unsupported("certificate"));
|
||||
assert!(matches!(check_encryption(&file(&[4, 0])), Err(DecryptError::Malformed(_))));
|
||||
// Rights management (IRM) keeps an EncryptedPackage but no EncryptionInfo.
|
||||
let irm = compound_file(&[("EncryptedPackage", &[0; 8])]);
|
||||
|
||||
@@ -395,6 +395,7 @@ fn sample() -> Workbook {
|
||||
font: Some("Arial".into()),
|
||||
text_color: Some(Color::Rgb(0x112233)),
|
||||
text_outline: Some((Color::Rgb(0xFF0000), 1.5)),
|
||||
text_shadow: Some(Color::Rgb(0x445566)),
|
||||
align: Some(ShapeTextAlign::Right),
|
||||
anchor: Some(ShapeTextAnchor::Bottom),
|
||||
rtl: true,
|
||||
|
||||
@@ -86,6 +86,72 @@ fn shape_text_without_rtl_writes_unchanged_ppr() {
|
||||
assert!(!xml.contains("<a:pPr"), "no paragraph properties without alignment or direction: {xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_art_keeps_right_to_left_paragraphs() {
|
||||
let mut wb = text_box("مرحبا\nبالعالم", ShapeFormat { rtl: true, align: Some(ShapeTextAlign::Right), ..Default::default() });
|
||||
wb.sheet_mut(0).unwrap().shapes[0].kind = ShapeKind::WordArt;
|
||||
let xml = written_drawing(&wb);
|
||||
assert!(xml.contains(r#"<a:prstTxWarp prst="textNoShape">"#));
|
||||
assert_eq!(xml.matches(r#"<a:pPr algn="r" rtl="1"/>"#).count(), 2);
|
||||
let (reopened, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
|
||||
let shape = &reopened.sheet(0).unwrap().shapes[0];
|
||||
assert_eq!(shape.kind, ShapeKind::WordArt);
|
||||
assert!(shape.format.rtl);
|
||||
assert_eq!(shape.format.align, Some(ShapeTextAlign::Right));
|
||||
assert_eq!(shape.text, "مرحبا\nبالعالم");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_art_with_empty_text_keeps_its_kind() {
|
||||
let mut wb = text_box("", ShapeFormat::default());
|
||||
wb.sheet_mut(0).unwrap().shapes[0].kind = ShapeKind::WordArt;
|
||||
let (reopened, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
|
||||
assert_eq!(reopened.sheet(0).unwrap().shapes[0].kind, ShapeKind::WordArt);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_warp_on_a_filled_ellipse_does_not_make_it_word_art() {
|
||||
let drawing = ARABIC_TEXT_BOX
|
||||
.replace(r#"<xdr:cNvSpPr txBox="1"/>"#, "<xdr:cNvSpPr/>")
|
||||
.replace(r#"prst="rect""#, r#"prst="ellipse""#)
|
||||
.replace(r#"anchor="t"/>"#, r#"anchor="t"><a:prstTxWarp prst="textTriangle"><a:avLst/></a:prstTxWarp></a:bodyPr>"#);
|
||||
let wb = read_drawing(&drawing);
|
||||
assert_eq!(wb.sheet(0).unwrap().shapes[0].kind, ShapeKind::Ellipse);
|
||||
}
|
||||
|
||||
/// `textNoShape` is the standard "no text warp" value, so on its own it does not make an ordinary
|
||||
/// shape WordArt: a filled ellipse that carries it reads and saves as the same filled ellipse.
|
||||
#[test]
|
||||
fn an_explicit_no_warp_on_a_filled_ellipse_stays_an_ellipse() {
|
||||
let drawing = ARABIC_TEXT_BOX
|
||||
.replace(r#"<xdr:cNvSpPr txBox="1"/>"#, "<xdr:cNvSpPr/>")
|
||||
.replace(
|
||||
r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>"#,
|
||||
r#"<a:prstGeom prst="ellipse"><a:avLst/></a:prstGeom><a:solidFill><a:srgbClr val="FF0000"/></a:solidFill>"#,
|
||||
)
|
||||
.replace(r#"anchor="t"/>"#, r#"anchor="t"><a:prstTxWarp prst="textNoShape"><a:avLst/></a:prstTxWarp></a:bodyPr>"#);
|
||||
let wb = read_drawing(&drawing);
|
||||
let shape = &wb.sheet(0).unwrap().shapes[0];
|
||||
assert_eq!(shape.kind, ShapeKind::Ellipse);
|
||||
assert_eq!(shape.fill, Color::Rgb(0xFF0000));
|
||||
let xml = written_drawing(&wb);
|
||||
assert!(xml.contains(r#"<a:prstGeom prst="ellipse">"#), "{xml}");
|
||||
assert!(xml.contains(r#"<a:solidFill><a:srgbClr val="FF0000"/></a:solidFill>"#), "{xml}");
|
||||
assert!(!xml.contains(r#"txBox="1""#), "{xml}");
|
||||
let (reopened, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
|
||||
assert_eq!(reopened.sheet(0).unwrap().shapes[0].kind, ShapeKind::Ellipse);
|
||||
// The same warp on a filled rectangle that is not a text box keeps it a rectangle too.
|
||||
let rect = drawing.replace(r#"prst="ellipse""#, r#"prst="rect""#);
|
||||
assert_eq!(read_drawing(&rect).sheet(0).unwrap().shapes[0].kind, ShapeKind::Rectangle);
|
||||
// WordArt as written (a text box) or as a fill-less, line-less rectangle still reads as WordArt.
|
||||
let text_box = ARABIC_TEXT_BOX.replace(r#"anchor="t"/>"#, r#"anchor="t"><a:prstTxWarp prst="textNoShape"><a:avLst/></a:prstTxWarp></a:bodyPr>"#);
|
||||
assert_eq!(read_drawing(&text_box).sheet(0).unwrap().shapes[0].kind, ShapeKind::WordArt);
|
||||
let bare = text_box
|
||||
.replace(r#"<xdr:cNvSpPr txBox="1"/>"#, "<xdr:cNvSpPr/>")
|
||||
.replace(r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>"#, r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/>"#);
|
||||
assert_eq!(read_drawing(&bare).sheet(0).unwrap().shapes[0].kind, ShapeKind::WordArt);
|
||||
}
|
||||
|
||||
/// #2170: a shape font typeface holding `"`, `<` or `&` is escaped as an attribute, so it can't
|
||||
/// close the `typeface` attribute early and inject others; it survives a save and reopen.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
//! `.xls` data validation: DVal and Dv records ([MS-XLS] 2.4.96, 2.4.95). Every record is
|
||||
//! assembled here from the spec's structures; no Excel-produced file is used. The expected
|
||||
//! rules are what the `.xlsx` reader gives for the same rule.
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef};
|
||||
use gridcraft_model::{CfOperator, ErrorStyle, Validation, ValidationKind, Workbook};
|
||||
|
||||
use super::xls_tests::{cat, compound, rec, u16s};
|
||||
use crate::{ReadReport, read_xls};
|
||||
|
||||
fn bof(dt: u16) -> Vec<u8> {
|
||||
rec(0x0809, &cat(&[&u16s(&[0x0600, dt, 0x0DBB, 0x07CC]), &[0; 8]]))
|
||||
}
|
||||
|
||||
fn eof() -> Vec<u8> {
|
||||
rec(0x000A, &[])
|
||||
}
|
||||
|
||||
/// A workbook of one worksheet, "Sheet1", holding `records`, with defined names (Lbl records,
|
||||
/// each a name and its formula) in the globals.
|
||||
fn workbook(names: &[(&str, &[u8])], records: &[Vec<u8>]) -> (Workbook, ReadReport) {
|
||||
let lbl = |name: &str, rgce: &[u8]| {
|
||||
rec(0x0018, &cat(&[&u16s(&[0]), &[0, name.len() as u8], &u16s(&[rgce.len() as u16, 0, 0]), &[0; 4], &[0], name.as_bytes(), rgce]))
|
||||
};
|
||||
let lbls: Vec<u8> = names.iter().flat_map(|(n, f)| lbl(n, f)).collect();
|
||||
let globals = |pos: u32| cat(&[&bof(0x0005), &rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, 6, 0], b"Sheet1"])), &lbls, &eof()]);
|
||||
let body: Vec<u8> = records.concat();
|
||||
let sheet = cat(&[&bof(0x0010), &body, &eof()]);
|
||||
let stream = cat(&[&globals(globals(0).len() as u32), &sheet]);
|
||||
read_xls(&compound("Workbook", &stream)).unwrap()
|
||||
}
|
||||
|
||||
/// DVal: flags, the input window's position, no drop-down object (-1), then `count` rules.
|
||||
fn dval(count: u32) -> Vec<u8> {
|
||||
rec(0x01B2, &cat(&[&[0, 0], &[0; 8], &(-1i32).to_le_bytes(), &count.to_le_bytes()]))
|
||||
}
|
||||
|
||||
// Dv flag bits ([MS-XLS] 2.4.95).
|
||||
const STR_LOOKUP: u32 = 1 << 7;
|
||||
const ALLOW_BLANK: u32 = 1 << 8;
|
||||
const SUPPRESS_COMBO: u32 = 1 << 9;
|
||||
const SHOW_INPUT: u32 = 1 << 18;
|
||||
const SHOW_ERROR: u32 = 1 << 19;
|
||||
/// What Excel sets on a new rule: blanks allowed, input message and error alert shown.
|
||||
const DEFAULTS: u32 = ALLOW_BLANK | SHOW_INPUT | SHOW_ERROR;
|
||||
|
||||
/// Dv flags: rule type, alert style, operator, plus `bits`.
|
||||
fn flags(kind: u32, alert: u32, op: u32, bits: u32) -> u32 {
|
||||
kind | (alert << 4) | (op << 20) | bits
|
||||
}
|
||||
|
||||
/// XLUnicodeString with compressed characters; "" is stored as Excel stores an empty
|
||||
/// message, a single NUL character.
|
||||
fn msg(s: &str) -> Vec<u8> {
|
||||
let s = if s.is_empty() { "\0" } else { s };
|
||||
cat(&[&(s.len() as u16).to_le_bytes(), &[0], s.as_bytes()])
|
||||
}
|
||||
|
||||
/// XLUnicodeString with UTF-16 characters.
|
||||
fn wide(s: &str) -> Vec<u8> {
|
||||
let units: Vec<u16> = s.encode_utf16().collect();
|
||||
cat(&[&(units.len() as u16).to_le_bytes(), &[1], &u16s(&units)])
|
||||
}
|
||||
|
||||
/// DVParsedFormula: size, 2 unused bytes (Excel leaves stray data there), the tokens.
|
||||
fn formula(rgce: &[u8]) -> Vec<u8> {
|
||||
cat(&[&(rgce.len() as u16).to_le_bytes(), &[0x6E, 0x74], rgce])
|
||||
}
|
||||
|
||||
/// SqRefU: a count, then each Ref8U as rows (first, last) and columns (first, last).
|
||||
fn sqref(ranges: &[[u16; 4]]) -> Vec<u8> {
|
||||
let list: Vec<u8> = ranges.iter().flat_map(|r| u16s(r)).collect();
|
||||
cat(&[&(ranges.len() as u16).to_le_bytes(), &list])
|
||||
}
|
||||
|
||||
/// A Dv record with its four messages (prompt title, error title, prompt, error).
|
||||
fn dv(flags: u32, msgs: [Vec<u8>; 4], f1: &[u8], f2: &[u8], ranges: &[[u16; 4]]) -> Vec<u8> {
|
||||
let [pt, et, p, e] = msgs;
|
||||
rec(0x01BE, &cat(&[&flags.to_le_bytes(), &pt, &et, &p, &e, &formula(f1), &formula(f2), &sqref(ranges)]))
|
||||
}
|
||||
|
||||
fn no_msgs() -> [Vec<u8>; 4] {
|
||||
[msg(""), msg(""), msg(""), msg("")]
|
||||
}
|
||||
|
||||
/// A Dv with Excel's default flags and no messages.
|
||||
fn rule(kind: u32, alert: u32, op: u32, f1: &[u8], f2: &[u8], ranges: &[[u16; 4]]) -> Vec<u8> {
|
||||
dv(flags(kind, alert, op, DEFAULTS), no_msgs(), f1, f2, ranges)
|
||||
}
|
||||
|
||||
fn range(a1: &str) -> RangeRef {
|
||||
RangeRef::parse(a1).unwrap()
|
||||
}
|
||||
|
||||
/// The `.xlsx` reader's result for a rule with Excel's default flags and no messages.
|
||||
fn expected(ranges: &[&str], kind: ValidationKind, op: CfOperator, f1: &str, f2: Option<&str>) -> Validation {
|
||||
Validation { ranges: ranges.iter().map(|r| range(r)).collect(), kind, op, f1: f1.into(), f2: f2.map(Into::into), ..Validation::default() }
|
||||
}
|
||||
|
||||
fn hex(s: &str) -> Vec<u8> {
|
||||
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
|
||||
}
|
||||
|
||||
const SKIPPED: &str = "Some data validation rules in .xls files could not be read and were skipped.";
|
||||
const IGNORED: &str = "Some data validation records in .xls files were ignored, as Excel ignores them: they have no DVal record before them, or come after the number of rules it gives.";
|
||||
|
||||
/// Each kind of rule, as Excel writes it, gives what the `.xlsx` reader gives for the same rule.
|
||||
#[test]
|
||||
fn reads_every_kind_of_rule() {
|
||||
use CfOperator::*;
|
||||
use ValidationKind::*;
|
||||
let rules = [
|
||||
// Whole number between 1 and 10, with all four messages and the stop alert.
|
||||
dv(
|
||||
flags(1, 0, 0, DEFAULTS),
|
||||
[msg("Whole"), msg("Oops"), msg("Enter 1 to 10"), msg("Not 1..10")],
|
||||
&hex("1e0100"),
|
||||
&hex("1e0a00"),
|
||||
&[[1, 5, 1, 1]],
|
||||
),
|
||||
// Decimal >= 0.5, warning alert.
|
||||
rule(2, 1, 6, &hex("1f000000000000e03f"), &[], &[[1, 5, 2, 2]]),
|
||||
// Literal list (NUL-separated items), information alert.
|
||||
dv(flags(3, 2, 0, DEFAULTS | STR_LOOKUP), no_msgs(), &hex("170e0052656400477265656e00426c7565"), &[], &[[1, 5, 3, 3]]),
|
||||
// List from an absolute range ($H$2:$H$5).
|
||||
rule(3, 0, 0, &hex("250100040007000700"), &[], &[[1, 5, 4, 4]]),
|
||||
// Date between two serials, time < 0.5, text length <= 5, whole not between 5 and 7.
|
||||
rule(4, 0, 0, &hex("1e37ab"), &hex("1ea4ac"), &[[1, 5, 5, 5]]),
|
||||
rule(5, 0, 5, &hex("1f000000000000e03f"), &[], &[[1, 5, 6, 6]]),
|
||||
rule(6, 0, 7, &hex("1e0500"), &[], &[[1, 5, 8, 8]]),
|
||||
rule(1, 0, 1, &hex("1e0500"), &hex("1e0700"), &[[1, 5, 9, 9]]),
|
||||
// Custom formula K2>A2 on K2:K6 (relative PtgRefN offsets 0 and -10 columns).
|
||||
rule(7, 0, 0, &hex("4c000000c04c0000f6c00d"), &[], &[[1, 5, 10, 10]]),
|
||||
// Any value, with an input message only.
|
||||
dv(flags(0, 0, 0, DEFAULTS), [msg("Note"), msg(""), msg("Anything goes"), msg("")], &[], &[], &[[1, 5, 13, 13]]),
|
||||
// A list from a defined name.
|
||||
rule(3, 0, 0, &hex("2301000000"), &[], &[[1, 5, 16, 16]]),
|
||||
// A dependent list: INDIRECT($S2) on T2:T6.
|
||||
rule(3, 0, 0, &hex("190100004c0000128022019400"), &[], &[[1, 5, 19, 19]]),
|
||||
// Decimal between two absolute cells.
|
||||
rule(2, 0, 0, &hex("4406000700"), &hex("4407000700"), &[[1, 5, 22, 22]]),
|
||||
];
|
||||
let mut records = vec![dval(rules.len() as u32)];
|
||||
records.extend(rules);
|
||||
let (wb, report) = workbook(&[("Colors", &[0x1E, 1, 0])], &records);
|
||||
let got = &wb.sheets[0].validations;
|
||||
let mut whole = expected(&["B2:B6"], Whole, Between, "1", Some("10"));
|
||||
(whole.input_title, whole.error_title, whole.input_message, whole.error_message) =
|
||||
("Whole".into(), "Oops".into(), "Enter 1 to 10".into(), "Not 1..10".into());
|
||||
let mut decimal = expected(&["C2:C6"], Decimal, GreaterOrEqual, "0.5", None);
|
||||
decimal.error_style = ErrorStyle::Warning;
|
||||
let mut literal = expected(&["D2:D6"], List, Between, "\"Red,Green,Blue\"", None);
|
||||
literal.error_style = ErrorStyle::Information;
|
||||
let mut any = expected(&["N2:N6"], Any, Between, "", None);
|
||||
(any.input_title, any.input_message) = ("Note".into(), "Anything goes".into());
|
||||
let want = vec![
|
||||
whole,
|
||||
decimal,
|
||||
literal,
|
||||
expected(&["E2:E6"], List, Between, "=$H$2:$H$5", None),
|
||||
expected(&["F2:F6"], Date, Between, "43831", Some("44196")),
|
||||
expected(&["G2:G6"], Time, Less, "0.5", None),
|
||||
expected(&["I2:I6"], TextLength, LessOrEqual, "5", None),
|
||||
expected(&["J2:J6"], Whole, NotBetween, "5", Some("7")),
|
||||
expected(&["K2:K6"], Custom, Between, "K2>A2", None),
|
||||
any,
|
||||
expected(&["Q2:Q6"], List, Between, "=Colors", None),
|
||||
expected(&["T2:T6"], List, Between, "=INDIRECT($S2)", None),
|
||||
expected(&["W2:W6"], Decimal, Between, "$H$7", Some("$H$8")),
|
||||
];
|
||||
assert_eq!(got.len(), want.len(), "{:?}", report.warnings);
|
||||
for (g, w) in got.iter().zip(&want) {
|
||||
assert_eq!(g, w);
|
||||
}
|
||||
assert!(!report.warnings.iter().any(|w| w == SKIPPED), "{:?}", report.warnings);
|
||||
assert!(report.warnings[0].contains("data validation were read"), "{:?}", report.warnings);
|
||||
}
|
||||
|
||||
/// The flags that switch features off; an operator stored on a list (where it means nothing);
|
||||
/// messages in UTF-16; literal items with quotes and spaces; a whole column.
|
||||
#[test]
|
||||
fn reads_flags_messages_lists_and_whole_columns() {
|
||||
let off = flags(3, 0, 0, STR_LOOKUP | SUPPRESS_COMBO);
|
||||
let records = [
|
||||
dval(4),
|
||||
dv(off, no_msgs(), &hex("170300610062"), &[], &[[1, 5, 14, 14]]),
|
||||
dv(
|
||||
flags(1, 0, 2, DEFAULTS),
|
||||
[wide("Café"), wide("Ошибка"), wide("日本語のテスト é ü"), wide("Nur 3 ✓")],
|
||||
&hex("1e0300"),
|
||||
&[],
|
||||
&[[1, 5, 20, 20]],
|
||||
),
|
||||
// typOperator 6 on a list, as found in real files: ignored.
|
||||
dv(flags(3, 0, 6, DEFAULTS | STR_LOOKUP), no_msgs(), &hex("170c00736179202268692200627965"), &[], &[[1, 5, 23, 23]]),
|
||||
// Rows 0-65535: every row of the BIFF8 grid.
|
||||
dv(flags(3, 0, 0, DEFAULTS | STR_LOOKUP), no_msgs(), &hex("170500780079007a"), &[], &[[0, 0xFFFF, 21, 21]]),
|
||||
];
|
||||
let (wb, report) = workbook(&[], &records);
|
||||
let got = &wb.sheets[0].validations;
|
||||
assert_eq!(got.len(), 4, "{:?}", report.warnings);
|
||||
let mut flags_off = expected(&["O2:O6"], ValidationKind::List, CfOperator::Between, "\"a,b\"", None);
|
||||
(flags_off.allow_blank, flags_off.in_cell_dropdown, flags_off.show_input, flags_off.show_error) = (false, false, false, false);
|
||||
assert_eq!(got[0], flags_off);
|
||||
let mut unicode = expected(&["U2:U6"], ValidationKind::Whole, CfOperator::Equal, "3", None);
|
||||
(unicode.input_title, unicode.error_title, unicode.input_message, unicode.error_message) =
|
||||
("Café".into(), "Ошибка".into(), "日本語のテスト é ü".into(), "Nur 3 ✓".into());
|
||||
assert_eq!(got[1], unicode);
|
||||
// The item `say "hi"` keeps its quotes, doubled as in `.xlsx`; the operator is ignored.
|
||||
assert_eq!(got[2], expected(&["X2:X6"], ValidationKind::List, CfOperator::Between, "\"say \"\"hi\"\",bye\"", None));
|
||||
let column = &got[3];
|
||||
assert_eq!(column.ranges, vec![RangeRef::cols(21, 21)]);
|
||||
assert!(column.ranges[0].is_full_cols());
|
||||
}
|
||||
|
||||
/// Relative references are offsets from the top-left cell of all the rule's ranges, whatever
|
||||
/// order the ranges are listed in (AF5:AF9 then AG1:AG3 has its formula written for AF1). An
|
||||
/// offset Excel couldn't keep inside the grid is stored as PtgRefErr and reads as #REF!.
|
||||
#[test]
|
||||
fn relative_references_start_from_the_top_left_of_all_ranges() {
|
||||
let records = [
|
||||
dval(3),
|
||||
// =AE1>0 for AF5:AF9 AG1:AG3 (column offset -1 from AF1).
|
||||
rule(7, 0, 0, &hex("4c0000ffc01e00000d"), &[], &[[4, 8, 31, 31], [0, 2, 32, 32]]),
|
||||
// =AB1>0 for AC1:AC3 AA5:AA9 (column offset +1 from AA1).
|
||||
rule(7, 0, 0, &hex("4c000001c01e00000d"), &[], &[[0, 2, 28, 28], [4, 8, 26, 26]]),
|
||||
// PtgRefErr: a reference that fell off the grid.
|
||||
rule(7, 0, 0, &hex("2a0000f4c01e00000d"), &[], &[[4, 8, 12, 12], [0, 2, 11, 11]]),
|
||||
];
|
||||
let (wb, report) = workbook(&[], &records);
|
||||
let got = &wb.sheets[0].validations;
|
||||
assert_eq!(got.len(), 3, "{:?}", report.warnings);
|
||||
assert_eq!(got[0].ranges, vec![range("AF5:AF9"), range("AG1:AG3")]);
|
||||
assert_eq!(got[0].f1, "AE1>0");
|
||||
assert_eq!(got[1].f1, "AB1>0");
|
||||
assert_eq!(got[2].f1, "#REF!>0");
|
||||
}
|
||||
|
||||
/// As in Excel, a DVal's count is authoritative: Dv records past it, or with no DVal before
|
||||
/// them, aren't read. That's said once, and not as a rule that failed to read.
|
||||
#[test]
|
||||
fn only_the_dvals_count_of_rules_is_read() {
|
||||
let a = || rule(1, 0, 4, &hex("1e0000"), &[], &[[0, 0, 0, 0]]);
|
||||
let b = || rule(1, 0, 4, &hex("1e0000"), &[], &[[1, 1, 0, 0]]);
|
||||
let (wb, report) = workbook(&[], &[dval(1), a(), b(), b()]);
|
||||
assert_eq!(wb.sheets[0].validations.iter().map(|v| v.ranges.clone()).collect::<Vec<_>>(), vec![vec![range("A1")]]);
|
||||
assert_eq!(report.warnings.iter().filter(|w| *w == IGNORED).count(), 1, "{:?}", report.warnings);
|
||||
assert!(!report.warnings.iter().any(|w| w == SKIPPED), "{:?}", report.warnings);
|
||||
let (wb, report) = workbook(&[], &[a(), b()]);
|
||||
assert!(wb.sheets[0].validations.is_empty());
|
||||
assert!(report.warnings.iter().any(|w| w == IGNORED), "{:?}", report.warnings);
|
||||
assert!(!report.warnings.iter().any(|w| w == SKIPPED), "{:?}", report.warnings);
|
||||
}
|
||||
|
||||
/// Malformed rules never fail the import. Where Excel opens such a file, the result is what
|
||||
/// Excel shows; where Excel refuses the whole file, that one rule is skipped with a warning.
|
||||
#[test]
|
||||
fn malformed_rules_are_repaired_or_skipped() {
|
||||
let ok = |row: u16| rule(1, 0, 4, &hex("1e0000"), &[], &[[row, row, 0, 0]]);
|
||||
let cases: Vec<(&str, Vec<u8>, Option<&str>)> = vec![
|
||||
// Excel opens an unknown type as "any value".
|
||||
("unknown type", rule(9, 0, 0, &hex("1e0100"), &[], &[[1, 5, 1, 1]]), Some("B2:B6")),
|
||||
// Excel refuses an unknown alert style, or an unknown operator on a rule that uses one.
|
||||
("unknown alert", rule(1, 5, 0, &hex("1e0100"), &hex("1e0a00"), &[[1, 5, 1, 1]]), None),
|
||||
("unknown operator", rule(1, 0, 9, &hex("1e0100"), &[], &[[1, 5, 1, 1]]), None),
|
||||
// Excel drops a reversed range, and clamps columns past IV.
|
||||
("reversed range", rule(1, 0, 4, &hex("1e0000"), &[], &[[5, 1, 2, 2]]), None),
|
||||
("past column IV", rule(1, 0, 4, &hex("1e0000"), &[], &[[1, 5, 3, 0x100]]), Some("D2:IV6")),
|
||||
// Excel refuses a range count or a formula size that runs past the record.
|
||||
(
|
||||
"too many ranges",
|
||||
rec(
|
||||
0x01BE,
|
||||
&cat(&[
|
||||
&flags(1, 0, 4, DEFAULTS).to_le_bytes(),
|
||||
&no_msgs().concat(),
|
||||
&formula(&hex("1e0000")),
|
||||
&formula(&[]),
|
||||
&u16s(&[2, 1, 5, 4, 4]),
|
||||
]),
|
||||
),
|
||||
None,
|
||||
),
|
||||
(
|
||||
"formula past the record",
|
||||
rec(0x01BE, &cat(&[&flags(1, 0, 4, DEFAULTS).to_le_bytes(), &no_msgs().concat(), &u16s(&[200, 0]), &hex("1e0000")])),
|
||||
None,
|
||||
),
|
||||
("truncated message", rec(0x01BE, &cat(&[&flags(1, 0, 4, DEFAULTS).to_le_bytes(), &u16s(&[40]), &[0], b"short"])), None),
|
||||
("empty record", rec(0x01BE, &[]), None),
|
||||
// A formula GridCraft can't decode (an unknown token).
|
||||
("unknown token", rule(7, 0, 0, &[0xFF], &[], &[[1, 5, 1, 1]]), None),
|
||||
// No ranges at all.
|
||||
("no ranges", rule(1, 0, 4, &hex("1e0000"), &[], &[]), None),
|
||||
];
|
||||
for (what, bad, want) in cases {
|
||||
let (wb, report) = workbook(&[], &[dval(3), ok(10), bad, ok(11)]);
|
||||
let got = &wb.sheets[0].validations;
|
||||
let ranges: Vec<RangeRef> = got.iter().flat_map(|v| v.ranges.clone()).collect();
|
||||
let mut expect = vec![range("A11")];
|
||||
expect.extend(want.map(range));
|
||||
expect.push(range("A12"));
|
||||
assert_eq!(ranges, expect, "{what}: {:?}", report.warnings);
|
||||
assert_eq!(report.warnings.iter().any(|w| w == SKIPPED), want.is_none(), "{what}: {:?}", report.warnings);
|
||||
if what == "unknown type" {
|
||||
assert_eq!((got[1].kind, got[1].f1.as_str(), got[1].f2.as_deref()), (ValidationKind::Any, "", None));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hostile counts and sizes never panic or allocate from the file's numbers.
|
||||
#[test]
|
||||
fn hostile_validation_records_never_panic() {
|
||||
let f = flags(1, 0, 4, DEFAULTS).to_le_bytes();
|
||||
let big = [
|
||||
rec(0x01B2, &[0xFF; 18]),
|
||||
rec(0x01BE, &cat(&[&f, &u16s(&[0xFFFF]), &[1]])),
|
||||
rec(0x01BE, &cat(&[&f, &no_msgs().concat(), &u16s(&[0xFFFF, 0xFFFF])])),
|
||||
rec(0x01BE, &cat(&[&f, &no_msgs().concat(), &formula(&[]), &formula(&[]), &u16s(&[0xFFFF])])),
|
||||
// A literal list whose PtgStr claims 255 UTF-16 characters and holds none.
|
||||
rec(
|
||||
0x01BE,
|
||||
&cat(&[
|
||||
&flags(3, 0, 0, DEFAULTS | STR_LOOKUP).to_le_bytes(),
|
||||
&no_msgs().concat(),
|
||||
&formula(&[0x17, 0xFF, 1]),
|
||||
&formula(&[]),
|
||||
&sqref(&[[0, 0, 0, 0]]),
|
||||
]),
|
||||
),
|
||||
// Every flag bit set: type 15, alert 7, operator 15.
|
||||
rec(0x01BE, &cat(&[&[0xFF; 4], &no_msgs().concat(), &formula(&[]), &formula(&[]), &sqref(&[[0, 0, 0, 0]])])),
|
||||
];
|
||||
let (wb, _) = workbook(&[], &big);
|
||||
assert!(wb.sheets[0].validations.is_empty());
|
||||
let mut many = vec![dval(u32::MAX)];
|
||||
many.extend((0..200u16).map(|r| rule(1, 0, 4, &hex("1e0000"), &[], &[[r, r, 0, 0]])));
|
||||
let (wb, _) = workbook(&[], &many);
|
||||
assert_eq!(wb.sheets[0].validations.len(), 200);
|
||||
assert_eq!(wb.sheets[0].validations[199].ranges, vec![RangeRef::cell(CellRef::new(199, 0))]);
|
||||
}
|
||||
|
||||
/// Rules with no messages cost no text, so a crafted file could otherwise keep millions of them:
|
||||
/// the workbook keeps 65,536 and says it skipped the rest.
|
||||
#[test]
|
||||
fn rules_past_the_workbook_limit_are_skipped() {
|
||||
let one = rule(0, 0, 0, &[], &[], &[[0, 0, 0, 0]]);
|
||||
let mut records = vec![dval(65_537)];
|
||||
records.extend(std::iter::repeat_n(one, 65_537));
|
||||
let (wb, report) = workbook(&[], &records);
|
||||
assert_eq!(wb.sheets[0].validations.len(), 65_536);
|
||||
// craft-xls counts the rules past its cap with the ones it could not read.
|
||||
assert_eq!(report.warnings.iter().filter(|w| *w == SKIPPED).count(), 1, "{:?}", report.warnings);
|
||||
assert!(!report.warnings.iter().any(|w| w == IGNORED), "{:?}", report.warnings);
|
||||
}
|
||||
@@ -133,18 +133,18 @@ pub(crate) fn compound(name: &str, data: &[u8]) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
fn rec(id: u16, payload: &[u8]) -> Vec<u8> {
|
||||
pub(super) fn rec(id: u16, payload: &[u8]) -> Vec<u8> {
|
||||
let mut v = id.to_le_bytes().to_vec();
|
||||
v.extend_from_slice(&(payload.len() as u16).to_le_bytes());
|
||||
v.extend_from_slice(payload);
|
||||
v
|
||||
}
|
||||
|
||||
fn cat(parts: &[&[u8]]) -> Vec<u8> {
|
||||
pub(super) fn cat(parts: &[&[u8]]) -> Vec<u8> {
|
||||
parts.concat()
|
||||
}
|
||||
|
||||
fn u16s(v: &[u16]) -> Vec<u8> {
|
||||
pub(super) fn u16s(v: &[u16]) -> Vec<u8> {
|
||||
v.iter().flat_map(|x| x.to_le_bytes()).collect()
|
||||
}
|
||||
|
||||
@@ -392,12 +392,12 @@ fn text_result_of_a_formula_kept_as_its_value() {
|
||||
|
||||
#[test]
|
||||
fn refuses_encrypted_old_and_foreign_files() {
|
||||
// FILEPASS in the globals: an encrypted workbook.
|
||||
// FILEPASS in the globals: a damaged encryption record must be rejected as such.
|
||||
let mut stream = bof(0x0005);
|
||||
stream.extend(rec(0x002F, &[1, 0, 1, 0]));
|
||||
stream.extend(eof());
|
||||
let err = read_xls(&compound("Workbook", &stream)).unwrap_err().to_string();
|
||||
assert!(err.contains("password-protected"), "{err}");
|
||||
assert!(err.contains("encryption record is damaged"), "{err}");
|
||||
// A BIFF5 (Excel 5.0/95) workbook keeps its data in a `Book` stream.
|
||||
let book = compound("Book", &stream);
|
||||
assert_eq!(sniff(&book), Format::Xls);
|
||||
@@ -484,6 +484,45 @@ fn dialog_sheets_are_skipped_not_read_as_worksheets() {
|
||||
assert!(report.warnings.iter().any(|w| w.contains("Dialog sheets")), "{:?}", report.warnings);
|
||||
}
|
||||
|
||||
/// A workbook opens on the sheet it was saved on (WINDOW1's `itabCur`), as Excel does: counted
|
||||
/// over all the file's sheets, skipped ones included. A hidden or skipped sheet, or no window
|
||||
/// record, falls back to the first visible sheet.
|
||||
#[test]
|
||||
fn opens_on_the_sheet_it_was_saved_on() {
|
||||
// BoundSheet: position, visibility (0 visible, 1 hidden), kind, name.
|
||||
let boundsheet = |pos: u32, state: u8, name: &str| rec(0x0085, &cat(&[&pos.to_le_bytes(), &[state, 0, name.len() as u8, 0], name.as_bytes()]));
|
||||
let window1 = |tab: u16| rec(0x003D, &u16s(&[0, 0, 0x4000, 0x2000, 0x0038, tab, 0, 1, 600]));
|
||||
let dialog = cat(&[&bof(0x0010), &rec(0x0081, &[0x10, 0x00]), &eof()]);
|
||||
let worksheet = |n: f64| cat(&[&bof(0x0010), &rec(0x0081, &[0x01, 0x04]), &rec(0x0203, &cat(&[&cell(0, 0, 0), &n.to_le_bytes()])), &eof()]);
|
||||
// Dialog1 (skipped), Sheet1, Sheet2, Hidden (hidden).
|
||||
let book = |window: &[u8]| {
|
||||
let globals = |p: [u32; 4]| {
|
||||
cat(&[
|
||||
&bof(0x0005),
|
||||
window,
|
||||
&boundsheet(p[0], 0, "Dialog1"),
|
||||
&boundsheet(p[1], 0, "Sheet1"),
|
||||
&boundsheet(p[2], 0, "Sheet2"),
|
||||
&boundsheet(p[3], 1, "Hidden"),
|
||||
&eof(),
|
||||
])
|
||||
};
|
||||
let (d, s1, s2, h) = (dialog.clone(), worksheet(1.0), worksheet(2.0), worksheet(3.0));
|
||||
let base = globals([0; 4]).len() as u32;
|
||||
let p = [base, base + d.len() as u32, base + (d.len() + s1.len()) as u32, base + (d.len() + s1.len() + s2.len()) as u32];
|
||||
let stream = cat(&[&globals(p), &d, &s1, &s2, &h]);
|
||||
let (wb, _) = read_xls(&compound("Workbook", &stream)).unwrap();
|
||||
wb.active().map(|s| s.name.clone()).unwrap_or_default()
|
||||
};
|
||||
assert_eq!(book(&window1(2)), "Sheet2", "itabCur 2 is Sheet2, counting the skipped dialog sheet");
|
||||
assert_eq!(book(&window1(1)), "Sheet1");
|
||||
assert_eq!(book(&window1(3)), "Sheet1", "a hidden sheet falls back to the first visible one");
|
||||
assert_eq!(book(&window1(0)), "Sheet1", "a skipped sheet falls back to the first visible one");
|
||||
assert_eq!(book(&window1(99)), "Sheet1", "an index past the sheets falls back too");
|
||||
assert_eq!(book(&[]), "Sheet1", "no window record");
|
||||
assert_eq!(book(&rec(0x003D, &u16s(&[0, 0, 0, 0, 0]))), "Sheet1", "a window record too short for the index");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sheets_share_one_record_budget() {
|
||||
// Many BoundSheet records point into one long substream (at successive nested BOFs), so each
|
||||
@@ -509,10 +548,13 @@ fn sheets_share_one_record_budget() {
|
||||
let file = compound("Workbook", &stream);
|
||||
// About n × 2,000 records in all: over a 100,000 shared allowance, though each sheet alone fits.
|
||||
assert!(matches!(
|
||||
crate::xls::read_xls_budgeted(&file, crate::xls::Budget::with_max_records(100_000), crate::FunctionInfo::default()),
|
||||
crate::xls::read_xls_budgeted(&file, craft_xls::Limits { max_records: 100_000, ..Default::default() }, crate::FunctionInfo::default()),
|
||||
Err(IoError::TooLarge(_))
|
||||
));
|
||||
assert!(crate::xls::read_xls_budgeted(&file, crate::xls::Budget::with_max_records(10_000_000), crate::FunctionInfo::default()).is_ok());
|
||||
assert!(
|
||||
crate::xls::read_xls_budgeted(&file, craft_xls::Limits { max_records: 10_000_000, ..Default::default() }, crate::FunctionInfo::default())
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
// Two sheets at the same offset are refused outright.
|
||||
let mut dup = bof(0x0005);
|
||||
@@ -768,15 +810,17 @@ fn xls_short_window2_is_ignored() {
|
||||
fn shared_formula_text_counts_against_the_text_allowance() {
|
||||
let file = compound("Workbook", &sample(false));
|
||||
// The smallest allowance that reads every string: nothing is left for expanded formulas.
|
||||
let left = (0..1000).find(|n| crate::xls::read_xls_budgeted(&file, crate::xls::Budget::with_text_left(*n), Default::default()).is_ok()).unwrap();
|
||||
let (wb, report) = crate::xls::read_xls_budgeted(&file, crate::xls::Budget::with_text_left(left), Default::default()).unwrap();
|
||||
let left = (0..1000)
|
||||
.find(|n| crate::xls::read_xls_budgeted(&file, craft_xls::Limits { max_text: *n, ..Default::default() }, Default::default()).is_ok())
|
||||
.unwrap();
|
||||
let (wb, report) = crate::xls::read_xls_budgeted(&file, craft_xls::Limits { max_text: left, ..Default::default() }, Default::default()).unwrap();
|
||||
assert_eq!(ftext(&wb, "B6"), None);
|
||||
assert_eq!(v(&wb, 0, "B6"), Value::Number(20.0));
|
||||
assert!(report.warnings.iter().any(|w| w.contains("shared-formula text")), "{:?}", report.warnings);
|
||||
// an array anchor after the cut-off still gets its formula
|
||||
assert!(ftext(&wb, "C5").is_some());
|
||||
// With room, the followers keep their formulas.
|
||||
let (wb, _) = crate::xls::read_xls_budgeted(&file, crate::xls::Budget::default(), Default::default()).unwrap();
|
||||
let (wb, _) = crate::xls::read_xls_budgeted(&file, Default::default(), Default::default()).unwrap();
|
||||
assert!(ftext(&wb, "B6").is_some());
|
||||
}
|
||||
|
||||
@@ -798,12 +842,14 @@ fn shared_formulas_do_not_starve_a_later_sheets_text() {
|
||||
let p1 = globals([0; 2]).len() as u32;
|
||||
let file = compound("Workbook", &cat(&[&globals([p1, p1 + one.len() as u32]), &one, &two]));
|
||||
// The smallest allowance that reads every string leaves nothing for expanded formulas.
|
||||
let left = (0..1000).find(|n| crate::xls::read_xls_budgeted(&file, crate::xls::Budget::with_text_left(*n), Default::default()).is_ok()).unwrap();
|
||||
let (wb, _) = crate::xls::read_xls_budgeted(&file, crate::xls::Budget::with_text_left(left), Default::default()).unwrap();
|
||||
let left = (0..1000)
|
||||
.find(|n| crate::xls::read_xls_budgeted(&file, craft_xls::Limits { max_text: *n, ..Default::default() }, Default::default()).is_ok())
|
||||
.unwrap();
|
||||
let (wb, _) = crate::xls::read_xls_budgeted(&file, craft_xls::Limits { max_text: left, ..Default::default() }, Default::default()).unwrap();
|
||||
assert_eq!(v(&wb, 1, "A1"), Value::text("abcdefgh"));
|
||||
assert_eq!(ftext(&wb, "B6"), None);
|
||||
assert_eq!(v(&wb, 0, "B6"), Value::Number(20.0));
|
||||
let (wb, _) = crate::xls::read_xls_budgeted(&file, crate::xls::Budget::default(), Default::default()).unwrap();
|
||||
let (wb, _) = crate::xls::read_xls_budgeted(&file, Default::default(), Default::default()).unwrap();
|
||||
assert!(ftext(&wb, "B6").is_some());
|
||||
}
|
||||
|
||||
@@ -826,7 +872,7 @@ fn xls_duplicate_sheet_names_are_refused() {
|
||||
g.extend(&sheet);
|
||||
compound("Workbook", &g)
|
||||
};
|
||||
let err = crate::xls::read_xls_budgeted(&file("One", "one"), crate::xls::Budget::default(), Default::default()).unwrap_err().to_string();
|
||||
let err = crate::xls::read_xls_budgeted(&file("One", "one"), Default::default(), Default::default()).unwrap_err().to_string();
|
||||
assert!(err.contains("already taken"), "{err}");
|
||||
assert!(crate::xls::read_xls_budgeted(&file("One", "Two"), crate::xls::Budget::default(), Default::default()).is_ok());
|
||||
assert!(crate::xls::read_xls_budgeted(&file("One", "Two"), Default::default(), Default::default()).is_ok());
|
||||
}
|
||||
|
||||
+383
-896
File diff suppressed because it is too large
Load Diff
@@ -1,551 +0,0 @@
|
||||
//! BIFF8 parsed formulas ([MS-XLS] 2.2.2, 2.5.198): reverse-Polish `Ptg` tokens back to an
|
||||
//! expression tree. Anything outside the common token set is reported as unsupported, and the
|
||||
//! caller keeps the cell's cached value instead.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{CellError, CellRef};
|
||||
use gridcraft_formula::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, UnOp};
|
||||
|
||||
use super::ftab::FTAB;
|
||||
|
||||
/// Formula token bytes decoded at most (Excel's limit is 1,800 per formula).
|
||||
const MAX_RGCE: usize = 16 * 1024;
|
||||
/// Values in one array constant at most.
|
||||
const MAX_ARRAY: usize = 65_536;
|
||||
|
||||
/// Why a formula could not be turned back into text.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(super) struct Unsupported(pub &'static str);
|
||||
|
||||
/// Where the formula lives: refs in cell formulas store coordinates; shared formulas store
|
||||
/// row/column offsets for relative parts; names store offsets for 3-D refs too.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub(super) enum Mode {
|
||||
Cell,
|
||||
Shared,
|
||||
Name,
|
||||
}
|
||||
|
||||
/// A supporting link ([MS-XLS] 2.4.271): only links to this workbook (and add-in function
|
||||
/// names) are resolved; external workbooks are never followed.
|
||||
#[derive(Default)]
|
||||
pub(super) struct SupBook {
|
||||
pub internal: bool,
|
||||
pub addin: bool,
|
||||
pub names: Vec<String>,
|
||||
}
|
||||
|
||||
/// XTI ([MS-XLS] 2.5.344): a supporting link and a range of its sheets.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct Xti {
|
||||
pub book: u16,
|
||||
pub first: i16,
|
||||
pub last: i16,
|
||||
}
|
||||
|
||||
/// Workbook-level tables formulas refer to.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Links {
|
||||
/// Every sheet's name in BoundSheet8 order (including skipped chart/macro sheets).
|
||||
pub sheets: Vec<String>,
|
||||
/// Defined names (Lbl records), in order; `None` for names that can't be referenced.
|
||||
pub names: Vec<String>,
|
||||
/// Per defined name: whether the import skips it (its formula can't be decoded).
|
||||
pub skipped: Vec<bool>,
|
||||
pub books: Vec<SupBook>,
|
||||
pub xti: Vec<Xti>,
|
||||
}
|
||||
|
||||
pub(super) fn function(iftab: u16) -> Option<(&'static str, i8)> {
|
||||
FTAB.binary_search_by_key(&iftab, |(i, _, _)| *i).ok().and_then(|i| FTAB.get(i)).map(|(_, n, a)| (*n, *a))
|
||||
}
|
||||
|
||||
fn error_code(code: u8) -> Option<CellError> {
|
||||
Some(match code {
|
||||
0x00 => CellError::Null,
|
||||
0x07 => CellError::Div0,
|
||||
0x0F => CellError::Value,
|
||||
0x17 => CellError::Ref,
|
||||
0x1D => CellError::Name,
|
||||
0x24 => CellError::Num,
|
||||
0x2A => CellError::NA,
|
||||
0x2B => CellError::GettingData,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn cell_error(code: u8) -> CellError {
|
||||
error_code(code).unwrap_or(CellError::NA)
|
||||
}
|
||||
|
||||
struct Bytes<'a> {
|
||||
b: &'a [u8],
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
const TRUNC: Unsupported = Unsupported("truncated formula");
|
||||
|
||||
impl Bytes<'_> {
|
||||
fn take(&mut self, n: usize) -> Result<&[u8], Unsupported> {
|
||||
let s = self.b.get(self.pos..self.pos.checked_add(n).ok_or(TRUNC)?).ok_or(TRUNC)?;
|
||||
self.pos += n;
|
||||
Ok(s)
|
||||
}
|
||||
fn u8(&mut self) -> Result<u8, Unsupported> {
|
||||
self.take(1)?.first().copied().ok_or(TRUNC)
|
||||
}
|
||||
fn u16(&mut self) -> Result<u16, Unsupported> {
|
||||
let s = self.take(2)?;
|
||||
Ok(u16::from_le_bytes([*s.first().ok_or(TRUNC)?, *s.get(1).ok_or(TRUNC)?]))
|
||||
}
|
||||
fn u32(&mut self) -> Result<u32, Unsupported> {
|
||||
Ok(u32::from(self.u16()?) | (u32::from(self.u16()?) << 16))
|
||||
}
|
||||
fn f64(&mut self) -> Result<f64, Unsupported> {
|
||||
let a: [u8; 8] = self.take(8)?.try_into().map_err(|_| TRUNC)?;
|
||||
Ok(f64::from_le_bytes(a))
|
||||
}
|
||||
fn chars(&mut self, cch: usize, high: bool) -> Result<String, Unsupported> {
|
||||
let units: Vec<u16> = if high {
|
||||
self.take(cch.checked_mul(2).ok_or(TRUNC)?)?.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect()
|
||||
} else {
|
||||
self.take(cch)?.iter().map(|&b| u16::from(b)).collect()
|
||||
};
|
||||
Ok(String::from_utf16_lossy(&units))
|
||||
}
|
||||
}
|
||||
|
||||
/// A column field (ColRelU / ColRelNegU): the 14-bit column and the two relative flags.
|
||||
fn col_field(v: u16) -> (u16, bool, bool) {
|
||||
(v & 0x3FFF, v & 0x4000 != 0, v & 0x8000 != 0)
|
||||
}
|
||||
|
||||
/// One end of a reference. In `relative` (RgceLocRel) form the relative parts are offsets
|
||||
/// from `base`, wrapped into the BIFF8 grid (65,536 × 256).
|
||||
fn anchor(row: u16, col: u16, base: CellRef, relative: bool) -> Anchor {
|
||||
let (c, col_rel, row_rel) = col_field(col);
|
||||
let (r, c) = if relative {
|
||||
let r = if row_rel { (u32::from(row).wrapping_add(base.row)) & 0xFFFF } else { u32::from(row) };
|
||||
// A 14-bit signed offset; adding modulo 256 gives the same column either way.
|
||||
let c = if col_rel { (u32::from(c).wrapping_add(base.col)) & 0xFF } else { u32::from(c) & 0xFF };
|
||||
(r, c)
|
||||
} else {
|
||||
(u32::from(row), u32::from(c) & 0xFF)
|
||||
};
|
||||
Anchor { row: r, col: c, row_abs: !row_rel, col_abs: !col_rel }
|
||||
}
|
||||
|
||||
/// An area; a range spanning every BIFF8 row (or column) becomes a whole-column (row) ref.
|
||||
fn area(r0: u16, r1: u16, c0: u16, c1: u16, base: CellRef, relative: bool) -> RefKind {
|
||||
let a = anchor(r0, c0, base, relative);
|
||||
let b = anchor(r1, c1, base, relative);
|
||||
if a.row == 0 && b.row == 0xFFFF {
|
||||
return RefKind::Cols(a.col, a.col_abs, b.col, b.col_abs);
|
||||
}
|
||||
if a.col == 0 && b.col == 0xFF {
|
||||
return RefKind::Rows(a.row, a.row_abs, b.row, b.row_abs);
|
||||
}
|
||||
RefKind::Range(a, b)
|
||||
}
|
||||
|
||||
impl Links {
|
||||
fn sheet_sel(&self, ixti: u16) -> Result<Option<SheetSel>, Unsupported> {
|
||||
let x = self.xti.get(usize::from(ixti)).ok_or(Unsupported("reference to a missing sheet link"))?;
|
||||
let book = self.books.get(usize::from(x.book)).ok_or(Unsupported("reference to a missing workbook link"))?;
|
||||
if !book.internal {
|
||||
return Err(Unsupported("references to other workbooks (external links)"));
|
||||
}
|
||||
let name = |i: i16| usize::try_from(i).ok().and_then(|i| self.sheets.get(i)).cloned();
|
||||
Ok(match (name(x.first), name(x.last)) {
|
||||
(Some(a), Some(_)) if x.first == x.last => Some(SheetSel::Named(a)),
|
||||
(Some(a), Some(b)) => Some(SheetSel::Span(a, b)),
|
||||
// A deleted sheet: the reference is #REF!.
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn extern_name(&self, ixti: u16, index: u32) -> Result<String, Unsupported> {
|
||||
let x = self.xti.get(usize::from(ixti)).ok_or(Unsupported("reference to a missing name link"))?;
|
||||
let book = self.books.get(usize::from(x.book)).ok_or(Unsupported("reference to a missing workbook link"))?;
|
||||
if !book.internal && !book.addin {
|
||||
return Err(Unsupported("names in other workbooks (external links)"));
|
||||
}
|
||||
let i = usize::try_from(index).ok().and_then(|i| i.checked_sub(1)).ok_or(Unsupported("invalid name index"))?;
|
||||
book.names.get(i).cloned().ok_or(Unsupported("reference to a missing name"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Strips the prefixes newer functions carry in files (`_xlfn.IFERROR`).
|
||||
fn function_name(name: &str) -> String {
|
||||
let n = name.strip_prefix("_xlfn.").or_else(|| name.strip_prefix("_xlws.")).unwrap_or(name);
|
||||
n.to_ascii_uppercase()
|
||||
}
|
||||
|
||||
/// Expression nesting at most, as in the formula parser: deeper trees would overflow the stack
|
||||
/// when printed, evaluated or dropped (a hostile file can chain thousands of PtgParen tokens).
|
||||
const MAX_DEPTH: usize = 256;
|
||||
|
||||
/// Operand stack entries: an expression and its nesting depth.
|
||||
type Stack = Vec<(Expr, usize)>;
|
||||
|
||||
/// Pops an operand, recording its depth in `kids` (the deepest operand this token consumed).
|
||||
fn pop(stack: &mut Stack, kids: &mut usize) -> Result<Expr, Unsupported> {
|
||||
let (e, d) = stack.pop().ok_or(Unsupported("malformed formula (operand missing)"))?;
|
||||
*kids = (*kids).max(d);
|
||||
Ok(e)
|
||||
}
|
||||
|
||||
fn pop_n(stack: &mut Stack, n: usize, kids: &mut usize) -> Result<Vec<Expr>, Unsupported> {
|
||||
let at = stack.len().checked_sub(n).ok_or(Unsupported("malformed formula (argument missing)"))?;
|
||||
let popped = stack.split_off(at);
|
||||
*kids = popped.iter().map(|(_, d)| *d).fold(*kids, usize::max);
|
||||
Ok(popped.into_iter().map(|(e, _)| e).collect())
|
||||
}
|
||||
|
||||
/// Pushes the result of a token one level above the operands it consumed.
|
||||
fn push(stack: &mut Stack, kids: usize, e: Expr) -> Result<(), Unsupported> {
|
||||
let depth = kids.saturating_add(1);
|
||||
if depth > MAX_DEPTH {
|
||||
return Err(Unsupported("formula nested too deeply"));
|
||||
}
|
||||
stack.push((e, depth));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decodes `rgce` (tokens) with `rgcb` (the extra data for array constants) evaluated at
|
||||
/// `base`. Returns the expression or why it can't be represented.
|
||||
pub(super) fn decode(rgce: &[u8], rgcb: &[u8], base: CellRef, mode: Mode, links: &Links) -> Result<Expr, Unsupported> {
|
||||
if rgce.len() > MAX_RGCE {
|
||||
return Err(Unsupported("formula too long"));
|
||||
}
|
||||
let mut t = Bytes { b: rgce, pos: 0 };
|
||||
let mut extra = Bytes { b: rgcb, pos: 0 };
|
||||
let mut stack: Stack = Vec::new();
|
||||
while t.pos < rgce.len() {
|
||||
let ptg = t.u8()?;
|
||||
let mut kids = 0usize;
|
||||
// Operand classes (reference / value / array) share one token number.
|
||||
let id = if ptg & 0x60 != 0 { 0x20 | (ptg & 0x1F) } else { ptg };
|
||||
match id {
|
||||
0x03..=0x11 => {
|
||||
let op = match id {
|
||||
0x03 => BinOp::Add,
|
||||
0x04 => BinOp::Sub,
|
||||
0x05 => BinOp::Mul,
|
||||
0x06 => BinOp::Div,
|
||||
0x07 => BinOp::Pow,
|
||||
0x08 => BinOp::Concat,
|
||||
0x09 => BinOp::Lt,
|
||||
0x0A => BinOp::Le,
|
||||
0x0B => BinOp::Eq,
|
||||
0x0C => BinOp::Ge,
|
||||
0x0D => BinOp::Gt,
|
||||
0x0E => BinOp::Ne,
|
||||
0x0F => BinOp::Intersect,
|
||||
0x10 => BinOp::Union,
|
||||
_ => BinOp::Range,
|
||||
};
|
||||
let b = pop(&mut stack, &mut kids)?;
|
||||
let a = pop(&mut stack, &mut kids)?;
|
||||
push(&mut stack, kids, Expr::Binary(op, Box::new(a), Box::new(b)))?;
|
||||
}
|
||||
0x12..=0x14 => {
|
||||
let op = match id {
|
||||
0x12 => UnOp::Plus,
|
||||
0x13 => UnOp::Neg,
|
||||
_ => UnOp::Percent,
|
||||
};
|
||||
let x = pop(&mut stack, &mut kids)?;
|
||||
push(&mut stack, kids, Expr::Unary(op, Box::new(x)))?;
|
||||
}
|
||||
0x15 => {
|
||||
let x = pop(&mut stack, &mut kids)?;
|
||||
push(&mut stack, kids, Expr::Paren(Box::new(x)))?;
|
||||
}
|
||||
0x16 => push(&mut stack, kids, Expr::Missing)?,
|
||||
0x17 => {
|
||||
let cch = usize::from(t.u8()?);
|
||||
let high = t.u8()? & 1 != 0;
|
||||
push(&mut stack, kids, Expr::Text(Arc::from(t.chars(cch, high)?)))?;
|
||||
}
|
||||
0x19 => {
|
||||
let kind = t.u8()?;
|
||||
if kind & 0x04 != 0 {
|
||||
// PtgAttrChoose: jump table of cOffset + 1 entries.
|
||||
let n = usize::from(t.u16()?) + 1;
|
||||
t.take(n * 2)?;
|
||||
} else {
|
||||
t.take(2)?;
|
||||
}
|
||||
if kind & 0x10 != 0 {
|
||||
// PtgAttrSum: SUM of the single operand.
|
||||
let x = pop(&mut stack, &mut kids)?;
|
||||
push(&mut stack, kids, Expr::Call("SUM".into(), vec![x]))?;
|
||||
}
|
||||
// Semi (volatile), If, Goto, Space, Baxcel: no effect on the expression.
|
||||
}
|
||||
0x1C => {
|
||||
let e = t.u8()?;
|
||||
push(&mut stack, kids, Expr::Error(error_code(e).ok_or(Unsupported("unknown error constant"))?))?;
|
||||
}
|
||||
0x1D => push(&mut stack, kids, Expr::Bool(t.u8()? != 0))?,
|
||||
0x1E => push(&mut stack, kids, Expr::Number(f64::from(t.u16()?)))?,
|
||||
0x1F => {
|
||||
let n = t.f64()?;
|
||||
if !n.is_finite() {
|
||||
return Err(Unsupported("invalid number constant"));
|
||||
}
|
||||
push(&mut stack, kids, Expr::Number(n))?;
|
||||
}
|
||||
0x20 => {
|
||||
t.take(7)?;
|
||||
push(&mut stack, kids, array_constant(&mut extra)?)?;
|
||||
}
|
||||
0x21 => {
|
||||
let iftab = t.u16()?;
|
||||
let (name, args) = function(iftab).ok_or(Unsupported("unknown function"))?;
|
||||
let n = usize::try_from(args).map_err(|_| Unsupported("unknown function argument count"))?;
|
||||
let args = pop_n(&mut stack, n, &mut kids)?;
|
||||
push(&mut stack, kids, Expr::Call(name.to_string(), args))?;
|
||||
}
|
||||
0x22 => {
|
||||
let n = usize::from(t.u8()?);
|
||||
let tab = t.u16()?;
|
||||
if tab & 0x8000 != 0 {
|
||||
return Err(Unsupported("macro sheet commands"));
|
||||
}
|
||||
let mut args = pop_n(&mut stack, n, &mut kids)?;
|
||||
if tab & 0x7FFF == 0x00FF {
|
||||
// A function called by name (add-in or newer function): the name comes first.
|
||||
if args.is_empty() {
|
||||
return Err(Unsupported("malformed function call"));
|
||||
}
|
||||
let name = match args.remove(0) {
|
||||
Expr::Name(n) => function_name(&n),
|
||||
_ => return Err(Unsupported("function call through an expression")),
|
||||
};
|
||||
push(&mut stack, kids, Expr::Call(name, args))?;
|
||||
} else {
|
||||
let (name, _) = function(tab & 0x7FFF).ok_or(Unsupported("unknown function"))?;
|
||||
push(&mut stack, kids, Expr::Call(name.to_string(), args))?;
|
||||
}
|
||||
}
|
||||
0x23 => {
|
||||
let i = usize::try_from(t.u32()?).ok().and_then(|i| i.checked_sub(1));
|
||||
let name = i.and_then(|i| links.names.get(i)).filter(|n| !n.is_empty()).ok_or(Unsupported("reference to a missing defined name"))?;
|
||||
// A cell using a name the import drops would calculate to #NAME?: keep its
|
||||
// saved result instead, as for references to other workbooks.
|
||||
if mode != Mode::Name && i.and_then(|i| links.skipped.get(i)) == Some(&true) {
|
||||
return Err(Unsupported("defined names GridCraft skipped"));
|
||||
}
|
||||
push(&mut stack, kids, Expr::Name(name.clone()))?;
|
||||
}
|
||||
0x24 | 0x2C => {
|
||||
let (r, c) = (t.u16()?, t.u16()?);
|
||||
let a = anchor(r, c, base, id == 0x2C);
|
||||
push(&mut stack, kids, Expr::Ref(Reference { sheet: SheetSel::Current, kind: RefKind::Cell(a) }))?;
|
||||
}
|
||||
0x25 | 0x2D => {
|
||||
let (r0, r1, c0, c1) = (t.u16()?, t.u16()?, t.u16()?, t.u16()?);
|
||||
let kind = area(r0, r1, c0, c1, base, id == 0x2D);
|
||||
push(&mut stack, kids, Expr::Ref(Reference { sheet: SheetSel::Current, kind }))?;
|
||||
}
|
||||
0x26 => {
|
||||
// PtgMemArea: a cache of the sub-expression that follows; skip its extra data.
|
||||
t.take(6)?;
|
||||
let n = usize::from(extra.u16()?);
|
||||
extra.take(n.checked_mul(8).ok_or(TRUNC)?)?;
|
||||
}
|
||||
0x27 | 0x28 => {
|
||||
t.take(6)?;
|
||||
}
|
||||
0x29 => {
|
||||
t.take(2)?;
|
||||
}
|
||||
0x2A => {
|
||||
t.take(4)?;
|
||||
push(&mut stack, kids, Expr::Error(CellError::Ref))?;
|
||||
}
|
||||
0x2B => {
|
||||
t.take(8)?;
|
||||
push(&mut stack, kids, Expr::Error(CellError::Ref))?;
|
||||
}
|
||||
0x39 => {
|
||||
let ixti = t.u16()?;
|
||||
let i = t.u32()?;
|
||||
push(&mut stack, kids, Expr::Name(links.extern_name(ixti, i)?))?;
|
||||
}
|
||||
0x3A => {
|
||||
let ixti = t.u16()?;
|
||||
let (r, c) = (t.u16()?, t.u16()?);
|
||||
let a = anchor(r, c, base, mode == Mode::Name);
|
||||
push(
|
||||
&mut stack,
|
||||
kids,
|
||||
match links.sheet_sel(ixti)? {
|
||||
Some(sheet) => Expr::Ref(Reference { sheet, kind: RefKind::Cell(a) }),
|
||||
None => Expr::Error(CellError::Ref),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
0x3B => {
|
||||
let ixti = t.u16()?;
|
||||
let (r0, r1, c0, c1) = (t.u16()?, t.u16()?, t.u16()?, t.u16()?);
|
||||
let kind = area(r0, r1, c0, c1, base, mode == Mode::Name);
|
||||
push(
|
||||
&mut stack,
|
||||
kids,
|
||||
match links.sheet_sel(ixti)? {
|
||||
Some(sheet) => Expr::Ref(Reference { sheet, kind }),
|
||||
None => Expr::Error(CellError::Ref),
|
||||
},
|
||||
)?;
|
||||
}
|
||||
0x3C => {
|
||||
t.take(6)?;
|
||||
push(&mut stack, kids, Expr::Error(CellError::Ref))?;
|
||||
}
|
||||
0x3D => {
|
||||
t.take(10)?;
|
||||
push(&mut stack, kids, Expr::Error(CellError::Ref))?;
|
||||
}
|
||||
0x01 => return Err(Unsupported("nested shared or array formula reference")),
|
||||
0x02 => return Err(Unsupported("what-if data tables")),
|
||||
0x18 => return Err(Unsupported("natural-language formulas")),
|
||||
_ => return Err(Unsupported("unknown formula token")),
|
||||
}
|
||||
if stack.len() > MAX_RGCE {
|
||||
return Err(Unsupported("formula too complex"));
|
||||
}
|
||||
}
|
||||
let (e, _) = stack.pop().ok_or(Unsupported("malformed formula (operand missing)"))?;
|
||||
if !stack.is_empty() {
|
||||
return Err(Unsupported("malformed formula (unused operands)"));
|
||||
}
|
||||
Ok(e)
|
||||
}
|
||||
|
||||
/// PtgExtraArray ([MS-XLS] 2.5.198.59): an array constant's values in row-major order.
|
||||
fn array_constant(b: &mut Bytes<'_>) -> Result<Expr, Unsupported> {
|
||||
let cols = usize::from(b.u8()?) + 1;
|
||||
let rows = usize::from(b.u16()?) + 1;
|
||||
if cols.saturating_mul(rows) > MAX_ARRAY {
|
||||
return Err(Unsupported("array constant too large"));
|
||||
}
|
||||
let mut out = Vec::with_capacity(rows);
|
||||
for _ in 0..rows {
|
||||
let mut row = Vec::with_capacity(cols);
|
||||
for _ in 0..cols {
|
||||
let kind = b.u8()?;
|
||||
row.push(match kind {
|
||||
0x00 => {
|
||||
b.take(8)?;
|
||||
Expr::Missing
|
||||
}
|
||||
0x01 => {
|
||||
let n = b.f64()?;
|
||||
if !n.is_finite() {
|
||||
return Err(Unsupported("invalid number constant"));
|
||||
}
|
||||
Expr::Number(n)
|
||||
}
|
||||
0x02 => {
|
||||
let cch = usize::from(b.u16()?);
|
||||
let high = b.u8()? & 1 != 0;
|
||||
Expr::Text(Arc::from(b.chars(cch, high)?))
|
||||
}
|
||||
0x04 => {
|
||||
let v = b.u8()?;
|
||||
b.take(7)?;
|
||||
Expr::Bool(v != 0)
|
||||
}
|
||||
0x10 => {
|
||||
let e = b.u8()?;
|
||||
b.take(7)?;
|
||||
Expr::Error(error_code(e).ok_or(Unsupported("unknown error constant"))?)
|
||||
}
|
||||
_ => return Err(Unsupported("unknown array constant value")),
|
||||
});
|
||||
}
|
||||
out.push(row);
|
||||
}
|
||||
Ok(Expr::Array(out))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn links() -> Links {
|
||||
Links {
|
||||
sheets: vec!["Sheet1".into(), "My Data".into()],
|
||||
names: vec!["Rate".into()],
|
||||
skipped: vec![false],
|
||||
books: vec![
|
||||
SupBook { internal: true, addin: false, names: vec![] },
|
||||
SupBook { internal: false, addin: true, names: vec!["_xlfn.IFERROR".into()] },
|
||||
SupBook::default(),
|
||||
],
|
||||
xti: vec![
|
||||
Xti { book: 0, first: 0, last: 1 },
|
||||
Xti { book: 1, first: -2, last: -2 },
|
||||
Xti { book: 2, first: 0, last: 0 },
|
||||
Xti { book: 0, first: -1, last: -1 },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn text(rgce: &[u8], rgcb: &[u8]) -> Result<String, Unsupported> {
|
||||
decode(rgce, rgcb, CellRef::new(0, 0), Mode::Cell, &links()).map(|e| gridcraft_formula::print(&e))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_common_tokens() {
|
||||
// {1,"a";TRUE,#N/A} via PtgArray + PtgExtraArray.
|
||||
let extra =
|
||||
[&[1u8, 1, 0][..], &[1], &1f64.to_le_bytes(), &[2, 1, 0, 0, b'a'], &[4, 1, 0, 0, 0, 0, 0, 0, 0], &[0x10, 0x2A, 0, 0, 0, 0, 0, 0, 0]]
|
||||
.concat();
|
||||
assert_eq!(text(&[0x60, 0, 0, 0, 0, 0, 0, 0], &extra).unwrap(), "{1,\"a\";TRUE,#N/A}");
|
||||
// SUM via PtgAttrSum over a 3-D span, whole column.
|
||||
let area3d = [&[0x3Bu8][..], &0u16.to_le_bytes(), &[0, 0, 0xFF, 0xFF, 1, 0, 1, 0]].concat();
|
||||
assert_eq!(text(&[area3d.as_slice(), &[0x19, 0x10, 0, 0]].concat(), &[]).unwrap(), "SUM('Sheet1:My Data'!$B:$B)");
|
||||
// IFERROR (a newer function) called by name through PtgNameX, with a missing argument.
|
||||
let namex = [&[0x39u8][..], &1u16.to_le_bytes(), &1u32.to_le_bytes()].concat();
|
||||
let call = [namex.as_slice(), &[0x1E, 5, 0, 0x16, 0x22, 3, 0xFF, 0]].concat();
|
||||
assert_eq!(text(&call, &[]).unwrap(), "IFERROR(5,)");
|
||||
// A deleted sheet becomes #REF!; the CHOOSE jump table is skipped.
|
||||
let ref_deleted = [&[0x3Au8][..], &3u16.to_le_bytes(), &[0, 0, 0, 0]].concat();
|
||||
let choose = [&[0x1Eu8, 1, 0][..], &[0x19, 0x04, 1, 0, 0, 0, 0, 0], ref_deleted.as_slice(), &[0x1E, 2, 0, 0x22, 3, 100, 0]].concat();
|
||||
assert_eq!(text(&choose, &[]).unwrap(), "CHOOSE(1,#REF!,2)");
|
||||
// External links, data tables and unbalanced stacks are refused, never guessed.
|
||||
let external = [&[0x3Au8][..], &2u16.to_le_bytes(), &[0, 0, 0, 0]].concat();
|
||||
assert!(text(&external, &[]).is_err());
|
||||
assert!(text(&[0x02, 0, 0, 0, 0], &[]).is_err());
|
||||
assert!(text(&[0x1E, 1, 0, 0x1E, 2, 0], &[]).is_err());
|
||||
assert!(text(&[0x03], &[]).is_err());
|
||||
assert!(text(&[0x21, 0xFF, 0x7F], &[]).is_err());
|
||||
assert!(text(&[0x60, 0, 0, 0, 0, 0, 0, 0], &[0xFF, 0xFF, 0xFF]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_nesting_is_refused_not_a_stack_overflow() {
|
||||
// 1 followed by thousands of PtgParen / PtgUminus tokens: a hostile file used to build a
|
||||
// 16K-deep tree that overflowed the stack when printed or dropped.
|
||||
for op in [0x15u8, 0x13] {
|
||||
let mut rgce = vec![0x1E, 1, 0];
|
||||
rgce.resize(MAX_RGCE, op);
|
||||
assert_eq!(text(&rgce, &[]), Err(Unsupported("formula nested too deeply")));
|
||||
}
|
||||
// Binary chains nest too: 1+1+1+... left-associated.
|
||||
let mut chain = vec![0x1E, 1, 0];
|
||||
while chain.len() + 4 <= MAX_RGCE {
|
||||
chain.extend([0x1E, 1, 0, 0x03]);
|
||||
}
|
||||
assert!(text(&chain, &[]).is_err());
|
||||
// Nesting up to the limit still decodes.
|
||||
let mut ok = vec![0x1E, 1, 0];
|
||||
ok.resize(3 + MAX_DEPTH - 1, 0x15);
|
||||
assert!(text(&ok, &[]).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -1,376 +0,0 @@
|
||||
// Generated from the Ftab table of [MS-XLS] section 2.5.198.17: (iftab, name, fixed argument
|
||||
// count or -1 when the function takes a variable number of arguments).
|
||||
pub(super) const FTAB: &[(u16, &str, i8)] = &[
|
||||
(0, "COUNT", -1),
|
||||
(1, "IF", -1),
|
||||
(2, "ISNA", 1),
|
||||
(3, "ISERROR", 1),
|
||||
(4, "SUM", -1),
|
||||
(5, "AVERAGE", -1),
|
||||
(6, "MIN", -1),
|
||||
(7, "MAX", -1),
|
||||
(8, "ROW", -1),
|
||||
(9, "COLUMN", -1),
|
||||
(10, "NA", 0),
|
||||
(11, "NPV", -1),
|
||||
(12, "STDEV", -1),
|
||||
(13, "DOLLAR", -1),
|
||||
(14, "FIXED", -1),
|
||||
(15, "SIN", 1),
|
||||
(16, "COS", 1),
|
||||
(17, "TAN", 1),
|
||||
(18, "ATAN", 1),
|
||||
(19, "PI", 0),
|
||||
(20, "SQRT", 1),
|
||||
(21, "EXP", 1),
|
||||
(22, "LN", 1),
|
||||
(23, "LOG10", 1),
|
||||
(24, "ABS", 1),
|
||||
(25, "INT", 1),
|
||||
(26, "SIGN", 1),
|
||||
(27, "ROUND", 2),
|
||||
(28, "LOOKUP", -1),
|
||||
(29, "INDEX", -1),
|
||||
(30, "REPT", 2),
|
||||
(31, "MID", 3),
|
||||
(32, "LEN", 1),
|
||||
(33, "VALUE", 1),
|
||||
(34, "TRUE", 0),
|
||||
(35, "FALSE", 0),
|
||||
(36, "AND", -1),
|
||||
(37, "OR", -1),
|
||||
(38, "NOT", 1),
|
||||
(39, "MOD", 2),
|
||||
(40, "DCOUNT", 3),
|
||||
(41, "DSUM", 3),
|
||||
(42, "DAVERAGE", 3),
|
||||
(43, "DMIN", 3),
|
||||
(44, "DMAX", 3),
|
||||
(45, "DSTDEV", 3),
|
||||
(46, "VAR", -1),
|
||||
(47, "DVAR", 3),
|
||||
(48, "TEXT", 2),
|
||||
(49, "LINEST", -1),
|
||||
(50, "TREND", -1),
|
||||
(51, "LOGEST", -1),
|
||||
(52, "GROWTH", -1),
|
||||
(53, "GOTO", 1),
|
||||
(54, "HALT", -1),
|
||||
(55, "RETURN", -1),
|
||||
(56, "PV", -1),
|
||||
(57, "FV", -1),
|
||||
(58, "NPER", -1),
|
||||
(59, "PMT", -1),
|
||||
(60, "RATE", -1),
|
||||
(61, "MIRR", 3),
|
||||
(62, "IRR", -1),
|
||||
(63, "RAND", 0),
|
||||
(64, "MATCH", -1),
|
||||
(65, "DATE", 3),
|
||||
(66, "TIME", 3),
|
||||
(67, "DAY", 1),
|
||||
(68, "MONTH", 1),
|
||||
(69, "YEAR", 1),
|
||||
(70, "WEEKDAY", -1),
|
||||
(71, "HOUR", 1),
|
||||
(72, "MINUTE", 1),
|
||||
(73, "SECOND", 1),
|
||||
(74, "NOW", 0),
|
||||
(75, "AREAS", 1),
|
||||
(76, "ROWS", 1),
|
||||
(77, "COLUMNS", 1),
|
||||
(78, "OFFSET", -1),
|
||||
(79, "ABSREF", 2),
|
||||
(80, "RELREF", 2),
|
||||
(81, "ARGUMENT", -1),
|
||||
(82, "SEARCH", -1),
|
||||
(83, "TRANSPOSE", 1),
|
||||
(84, "ERROR", -1),
|
||||
(85, "STEP", 0),
|
||||
(86, "TYPE", 1),
|
||||
(87, "ECHO", -1),
|
||||
(88, "SET.NAME", -1),
|
||||
(89, "CALLER", 0),
|
||||
(90, "DEREF", 1),
|
||||
(91, "WINDOWS", -1),
|
||||
(92, "SERIES", -1),
|
||||
(93, "DOCUMENTS", -1),
|
||||
(94, "ACTIVE.CELL", 0),
|
||||
(95, "SELECTION", 0),
|
||||
(96, "RESULT", -1),
|
||||
(97, "ATAN2", 2),
|
||||
(98, "ASIN", 1),
|
||||
(99, "ACOS", 1),
|
||||
(100, "CHOOSE", -1),
|
||||
(101, "HLOOKUP", -1),
|
||||
(102, "VLOOKUP", -1),
|
||||
(103, "LINKS", -1),
|
||||
(104, "INPUT", -1),
|
||||
(105, "ISREF", 1),
|
||||
(106, "GET.FORMULA", 1),
|
||||
(107, "GET.NAME", -1),
|
||||
(108, "SET.VALUE", 2),
|
||||
(109, "LOG", -1),
|
||||
(110, "EXEC", -1),
|
||||
(111, "CHAR", 1),
|
||||
(112, "LOWER", 1),
|
||||
(113, "UPPER", 1),
|
||||
(114, "PROPER", 1),
|
||||
(115, "LEFT", -1),
|
||||
(116, "RIGHT", -1),
|
||||
(117, "EXACT", 2),
|
||||
(118, "TRIM", 1),
|
||||
(119, "REPLACE", 4),
|
||||
(120, "SUBSTITUTE", -1),
|
||||
(121, "CODE", 1),
|
||||
(122, "NAMES", -1),
|
||||
(123, "DIRECTORY", -1),
|
||||
(124, "FIND", -1),
|
||||
(125, "CELL", -1),
|
||||
(126, "ISERR", 1),
|
||||
(127, "ISTEXT", 1),
|
||||
(128, "ISNUMBER", 1),
|
||||
(129, "ISBLANK", 1),
|
||||
(130, "T", 1),
|
||||
(131, "N", 1),
|
||||
(132, "FOPEN", -1),
|
||||
(133, "FCLOSE", 1),
|
||||
(134, "FSIZE", 1),
|
||||
(135, "FREADLN", 1),
|
||||
(136, "FREAD", 2),
|
||||
(137, "FWRITELN", 2),
|
||||
(138, "FWRITE", 2),
|
||||
(139, "FPOS", -1),
|
||||
(140, "DATEVALUE", 1),
|
||||
(141, "TIMEVALUE", 1),
|
||||
(142, "SLN", 3),
|
||||
(143, "SYD", 4),
|
||||
(144, "DDB", -1),
|
||||
(145, "GET.DEF", -1),
|
||||
(146, "REFTEXT", -1),
|
||||
(147, "TEXTREF", -1),
|
||||
(148, "INDIRECT", -1),
|
||||
(149, "REGISTER", -1),
|
||||
(150, "CALL", -1),
|
||||
(151, "ADD.BAR", -1),
|
||||
(152, "ADD.MENU", -1),
|
||||
(153, "ADD.COMMAND", -1),
|
||||
(154, "ENABLE.COMMAND", -1),
|
||||
(155, "CHECK.COMMAND", -1),
|
||||
(156, "RENAME.COMMAND", -1),
|
||||
(157, "SHOW.BAR", -1),
|
||||
(158, "DELETE.MENU", -1),
|
||||
(159, "DELETE.COMMAND", -1),
|
||||
(160, "GET.CHART.ITEM", -1),
|
||||
(161, "DIALOG.BOX", 1),
|
||||
(162, "CLEAN", 1),
|
||||
(163, "MDETERM", 1),
|
||||
(164, "MINVERSE", 1),
|
||||
(165, "MMULT", 2),
|
||||
(166, "FILES", -1),
|
||||
(167, "IPMT", -1),
|
||||
(168, "PPMT", -1),
|
||||
(169, "COUNTA", -1),
|
||||
(170, "CANCEL.KEY", -1),
|
||||
(171, "FOR", -1),
|
||||
(172, "WHILE", 1),
|
||||
(173, "BREAK", 0),
|
||||
(174, "NEXT", 0),
|
||||
(175, "INITIATE", 2),
|
||||
(176, "REQUEST", 2),
|
||||
(177, "POKE", 3),
|
||||
(178, "EXECUTE", 2),
|
||||
(179, "TERMINATE", 1),
|
||||
(180, "RESTART", -1),
|
||||
(181, "HELP", -1),
|
||||
(182, "GET.BAR", -1),
|
||||
(183, "PRODUCT", -1),
|
||||
(184, "FACT", 1),
|
||||
(185, "GET.CELL", -1),
|
||||
(186, "GET.WORKSPACE", 1),
|
||||
(187, "GET.WINDOW", -1),
|
||||
(188, "GET.DOCUMENT", -1),
|
||||
(189, "DPRODUCT", 3),
|
||||
(190, "ISNONTEXT", 1),
|
||||
(191, "GET.NOTE", -1),
|
||||
(192, "NOTE", -1),
|
||||
(193, "STDEVP", -1),
|
||||
(194, "VARP", -1),
|
||||
(195, "DSTDEVP", 3),
|
||||
(196, "DVARP", 3),
|
||||
(197, "TRUNC", -1),
|
||||
(198, "ISLOGICAL", 1),
|
||||
(199, "DCOUNTA", 3),
|
||||
(200, "DELETE.BAR", 1),
|
||||
(201, "UNREGISTER", 1),
|
||||
(204, "USDOLLAR", -1),
|
||||
(205, "FINDB", -1),
|
||||
(206, "SEARCHB", -1),
|
||||
(207, "REPLACEB", 4),
|
||||
(208, "LEFTB", -1),
|
||||
(209, "RIGHTB", -1),
|
||||
(210, "MIDB", 3),
|
||||
(211, "LENB", 1),
|
||||
(212, "ROUNDUP", 2),
|
||||
(213, "ROUNDDOWN", 2),
|
||||
(214, "ASC", 1),
|
||||
(215, "DBCS", 1),
|
||||
(216, "RANK", -1),
|
||||
(219, "ADDRESS", -1),
|
||||
(220, "DAYS360", -1),
|
||||
(221, "TODAY", 0),
|
||||
(222, "VDB", -1),
|
||||
(223, "ELSE", 0),
|
||||
(224, "ELSE.IF", 1),
|
||||
(225, "END.IF", 0),
|
||||
(226, "FOR.CELL", -1),
|
||||
(227, "MEDIAN", -1),
|
||||
(228, "SUMPRODUCT", -1),
|
||||
(229, "SINH", 1),
|
||||
(230, "COSH", 1),
|
||||
(231, "TANH", 1),
|
||||
(232, "ASINH", 1),
|
||||
(233, "ACOSH", 1),
|
||||
(234, "ATANH", 1),
|
||||
(235, "DGET", 3),
|
||||
(236, "CREATE.OBJECT", -1),
|
||||
(237, "VOLATILE", -1),
|
||||
(238, "LAST.ERROR", 0),
|
||||
(239, "CUSTOM.UNDO", -1),
|
||||
(240, "CUSTOM.REPEAT", -1),
|
||||
(241, "FORMULA.CONVERT", -1),
|
||||
(242, "GET.LINK.INFO", -1),
|
||||
(243, "TEXT.BOX", -1),
|
||||
(244, "INFO", 1),
|
||||
(245, "GROUP", 0),
|
||||
(246, "GET.OBJECT", -1),
|
||||
(247, "DB", -1),
|
||||
(248, "PAUSE", -1),
|
||||
(251, "RESUME", -1),
|
||||
(252, "FREQUENCY", 2),
|
||||
(253, "ADD.TOOLBAR", -1),
|
||||
(254, "DELETE.TOOLBAR", 1),
|
||||
(256, "RESET.TOOLBAR", 1),
|
||||
(257, "EVALUATE", 1),
|
||||
(258, "GET.TOOLBAR", -1),
|
||||
(259, "GET.TOOL", -1),
|
||||
(260, "SPELLING.CHECK", -1),
|
||||
(261, "ERROR.TYPE", 1),
|
||||
(262, "APP.TITLE", -1),
|
||||
(263, "WINDOW.TITLE", -1),
|
||||
(264, "SAVE.TOOLBAR", -1),
|
||||
(265, "ENABLE.TOOL", 3),
|
||||
(266, "PRESS.TOOL", 3),
|
||||
(267, "REGISTER.ID", -1),
|
||||
(268, "GET.WORKBOOK", -1),
|
||||
(269, "AVEDEV", -1),
|
||||
(270, "BETADIST", -1),
|
||||
(271, "GAMMALN", 1),
|
||||
(272, "BETAINV", -1),
|
||||
(273, "BINOMDIST", 4),
|
||||
(274, "CHIDIST", 2),
|
||||
(275, "CHIINV", 2),
|
||||
(276, "COMBIN", 2),
|
||||
(277, "CONFIDENCE", 3),
|
||||
(278, "CRITBINOM", 3),
|
||||
(279, "EVEN", 1),
|
||||
(280, "EXPONDIST", 3),
|
||||
(281, "FDIST", 3),
|
||||
(282, "FINV", 3),
|
||||
(283, "FISHER", 1),
|
||||
(284, "FISHERINV", 1),
|
||||
(285, "FLOOR", 2),
|
||||
(286, "GAMMADIST", 4),
|
||||
(287, "GAMMAINV", 3),
|
||||
(288, "CEILING", 2),
|
||||
(289, "HYPGEOMDIST", 4),
|
||||
(290, "LOGNORMDIST", 3),
|
||||
(291, "LOGINV", 3),
|
||||
(292, "NEGBINOMDIST", 3),
|
||||
(293, "NORMDIST", 4),
|
||||
(294, "NORMSDIST", 1),
|
||||
(295, "NORMINV", 3),
|
||||
(296, "NORMSINV", 1),
|
||||
(297, "STANDARDIZE", 3),
|
||||
(298, "ODD", 1),
|
||||
(299, "PERMUT", 2),
|
||||
(300, "POISSON", 3),
|
||||
(301, "TDIST", 3),
|
||||
(302, "WEIBULL", 4),
|
||||
(303, "SUMXMY2", 2),
|
||||
(304, "SUMX2MY2", 2),
|
||||
(305, "SUMX2PY2", 2),
|
||||
(306, "CHITEST", 2),
|
||||
(307, "CORREL", 2),
|
||||
(308, "COVAR", 2),
|
||||
(309, "FORECAST", 3),
|
||||
(310, "FTEST", 2),
|
||||
(311, "INTERCEPT", 2),
|
||||
(312, "PEARSON", 2),
|
||||
(313, "RSQ", 2),
|
||||
(314, "STEYX", 2),
|
||||
(315, "SLOPE", 2),
|
||||
(316, "TTEST", 4),
|
||||
(317, "PROB", -1),
|
||||
(318, "DEVSQ", -1),
|
||||
(319, "GEOMEAN", -1),
|
||||
(320, "HARMEAN", -1),
|
||||
(321, "SUMSQ", -1),
|
||||
(322, "KURT", -1),
|
||||
(323, "SKEW", -1),
|
||||
(324, "ZTEST", -1),
|
||||
(325, "LARGE", 2),
|
||||
(326, "SMALL", 2),
|
||||
(327, "QUARTILE", 2),
|
||||
(328, "PERCENTILE", 2),
|
||||
(329, "PERCENTRANK", -1),
|
||||
(330, "MODE", -1),
|
||||
(331, "TRIMMEAN", 2),
|
||||
(332, "TINV", 2),
|
||||
(334, "MOVIE.COMMAND", -1),
|
||||
(335, "GET.MOVIE", -1),
|
||||
(336, "CONCATENATE", -1),
|
||||
(337, "POWER", 2),
|
||||
(338, "PIVOT.ADD.DATA", -1),
|
||||
(339, "GET.PIVOT.TABLE", -1),
|
||||
(340, "GET.PIVOT.FIELD", -1),
|
||||
(341, "GET.PIVOT.ITEM", -1),
|
||||
(342, "RADIANS", 1),
|
||||
(343, "DEGREES", 1),
|
||||
(344, "SUBTOTAL", -1),
|
||||
(345, "SUMIF", -1),
|
||||
(346, "COUNTIF", 2),
|
||||
(347, "COUNTBLANK", 1),
|
||||
(348, "SCENARIO.GET", -1),
|
||||
(349, "OPTIONS.LISTS.GET", 1),
|
||||
(350, "ISPMT", 4),
|
||||
(351, "DATEDIF", 3),
|
||||
(352, "DATESTRING", 1),
|
||||
(353, "NUMBERSTRING", 2),
|
||||
(354, "ROMAN", -1),
|
||||
(355, "OPEN.DIALOG", -1),
|
||||
(356, "SAVE.DIALOG", -1),
|
||||
(357, "VIEW.GET", -1),
|
||||
(358, "GETPIVOTDATA", -1),
|
||||
(359, "HYPERLINK", -1),
|
||||
(360, "PHONETIC", 1),
|
||||
(361, "AVERAGEA", -1),
|
||||
(362, "MAXA", -1),
|
||||
(363, "MINA", -1),
|
||||
(364, "STDEVPA", -1),
|
||||
(365, "VARPA", -1),
|
||||
(366, "STDEVA", -1),
|
||||
(367, "VARA", -1),
|
||||
(368, "BAHTTEXT", 1),
|
||||
(369, "THAIDAYOFWEEK", 1),
|
||||
(370, "THAIDIGIT", 1),
|
||||
(371, "THAIMONTHOFYEAR", 1),
|
||||
(372, "THAINUMSOUND", 1),
|
||||
(373, "THAINUMSTRING", 1),
|
||||
(374, "THAISTRINGLENGTH", 1),
|
||||
(375, "ISTHAIDIGIT", 1),
|
||||
(376, "ROUNDBAHTDOWN", 1),
|
||||
(377, "ROUNDBAHTUP", 1),
|
||||
(378, "THAIYEAR", 1),
|
||||
(379, "RTD", -1),
|
||||
];
|
||||
@@ -1,223 +0,0 @@
|
||||
//! BIFF8 record framing ([MS-XLS] 2.1.4) and the string structures ([MS-XLS] 2.5.240,
|
||||
//! 2.5.293, 2.5.294) that may continue across `Continue` records.
|
||||
|
||||
use super::invalid;
|
||||
use crate::IoError;
|
||||
|
||||
/// `Continue` record type.
|
||||
pub(super) const CONTINUE: u16 = 0x003C;
|
||||
/// Characters in one string at most (Excel's cell limit is 32,767; the SST may hold longer).
|
||||
const MAX_CHARS: usize = 1 << 20;
|
||||
/// `Continue` records gathered for one record at most.
|
||||
const MAX_PARTS: usize = 1 << 16;
|
||||
|
||||
/// One record and the payloads of the `Continue` records that follow it.
|
||||
pub(super) struct Record<'a> {
|
||||
pub id: u16,
|
||||
pub parts: Vec<&'a [u8]>,
|
||||
}
|
||||
|
||||
/// Iterates over the records of a stream starting at `pos`.
|
||||
pub(super) struct Records<'a> {
|
||||
stream: &'a [u8],
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> Records<'a> {
|
||||
pub fn new(stream: &'a [u8], pos: usize) -> Self {
|
||||
Records { stream, pos }
|
||||
}
|
||||
|
||||
fn raw(&mut self) -> Result<Option<(u16, &'a [u8])>, IoError> {
|
||||
if self.pos >= self.stream.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let head = self.stream.get(self.pos..self.pos.saturating_add(4)).ok_or_else(|| invalid("truncated record header"))?;
|
||||
let id = u16::from_le_bytes([head.first().copied().unwrap_or(0), head.get(1).copied().unwrap_or(0)]);
|
||||
let len = usize::from(u16::from_le_bytes([head.get(2).copied().unwrap_or(0), head.get(3).copied().unwrap_or(0)]));
|
||||
let start = self.pos + 4;
|
||||
let data = self.stream.get(start..start.saturating_add(len)).ok_or_else(|| invalid("truncated record"))?;
|
||||
self.pos = start + len;
|
||||
Ok(Some((id, data)))
|
||||
}
|
||||
|
||||
fn peek_id(&self) -> Option<u16> {
|
||||
let h = self.stream.get(self.pos..self.pos.checked_add(2)?)?;
|
||||
Some(u16::from_le_bytes([*h.first()?, *h.get(1)?]))
|
||||
}
|
||||
|
||||
/// The next record with its `Continue` records, or `None` at the end of the stream.
|
||||
pub fn next_record(&mut self) -> Result<Option<Record<'a>>, IoError> {
|
||||
let Some((id, data)) = self.raw()? else { return Ok(None) };
|
||||
let mut parts = vec![data];
|
||||
while self.peek_id() == Some(CONTINUE) && parts.len() < MAX_PARTS {
|
||||
if let Some((_, more)) = self.raw()? {
|
||||
parts.push(more);
|
||||
}
|
||||
}
|
||||
Ok(Some(Record { id, parts }))
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a record's payload, crossing into its `Continue` records when needed.
|
||||
pub(super) struct Cursor<'a, 'b> {
|
||||
parts: &'b [&'a [u8]],
|
||||
part: usize,
|
||||
pos: usize,
|
||||
/// Bytes in every part after the current one, kept up to date as parts advance, so
|
||||
/// `remaining()` is constant-time however many `Continue` records a string session crosses.
|
||||
after: usize,
|
||||
}
|
||||
|
||||
impl<'a, 'b> Cursor<'a, 'b> {
|
||||
pub fn new(parts: &'b [&'a [u8]]) -> Self {
|
||||
let after = parts.iter().skip(1).map(|p| p.len()).sum::<usize>();
|
||||
Cursor { parts, part: 0, pos: 0, after }
|
||||
}
|
||||
|
||||
/// Skips to the next part when the current one is used up. Returns whether a new part began.
|
||||
fn settle(&mut self) -> bool {
|
||||
let mut moved = false;
|
||||
while self.parts.get(self.part).is_some_and(|p| self.pos >= p.len()) && self.part + 1 < self.parts.len() {
|
||||
self.after = self.after.saturating_sub(self.parts[self.part + 1].len());
|
||||
self.part += 1;
|
||||
self.pos = 0;
|
||||
moved = true;
|
||||
}
|
||||
moved
|
||||
}
|
||||
|
||||
pub fn u8(&mut self) -> Result<u8, IoError> {
|
||||
self.settle();
|
||||
let b = self.parts.get(self.part).and_then(|p| p.get(self.pos)).copied().ok_or_else(|| invalid("record is shorter than its contents"))?;
|
||||
self.pos += 1;
|
||||
Ok(b)
|
||||
}
|
||||
|
||||
pub fn u16(&mut self) -> Result<u16, IoError> {
|
||||
Ok(u16::from_le_bytes([self.u8()?, self.u8()?]))
|
||||
}
|
||||
|
||||
pub fn u32(&mut self) -> Result<u32, IoError> {
|
||||
Ok(u32::from_le_bytes([self.u8()?, self.u8()?, self.u8()?, self.u8()?]))
|
||||
}
|
||||
|
||||
pub fn f64(&mut self) -> Result<f64, IoError> {
|
||||
let mut b = [0u8; 8];
|
||||
for x in b.iter_mut() {
|
||||
*x = self.u8()?;
|
||||
}
|
||||
Ok(f64::from_le_bytes(b))
|
||||
}
|
||||
|
||||
pub fn skip(&mut self, n: usize) -> Result<(), IoError> {
|
||||
for _ in 0..n {
|
||||
self.u8()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bytes left in the record (all parts), constant-time in the part count.
|
||||
pub fn remaining(&self) -> usize {
|
||||
let here = self.parts.get(self.part).map_or(0, |p| p.len().saturating_sub(self.pos));
|
||||
here.saturating_add(self.after)
|
||||
}
|
||||
|
||||
/// The rest of the record as one buffer.
|
||||
pub fn rest(&mut self) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(self.remaining());
|
||||
if let Some(p) = self.parts.get(self.part) {
|
||||
out.extend_from_slice(p.get(self.pos..).unwrap_or(&[]));
|
||||
}
|
||||
for p in self.parts.iter().skip(self.part + 1) {
|
||||
out.extend_from_slice(p);
|
||||
}
|
||||
self.part = self.parts.len();
|
||||
self.pos = 0;
|
||||
self.after = 0;
|
||||
out
|
||||
}
|
||||
|
||||
/// `cch` characters, compressed (one byte each) or UTF-16. When the characters run into
|
||||
/// a `Continue` record, that record starts with a fresh compression flag ([MS-XLS] 2.5.293).
|
||||
pub fn chars(&mut self, cch: usize, mut high: bool) -> Result<String, IoError> {
|
||||
if cch > MAX_CHARS {
|
||||
return Err(invalid("string too long"));
|
||||
}
|
||||
let mut units: Vec<u16> = Vec::with_capacity(cch.min(self.remaining()));
|
||||
while units.len() < cch {
|
||||
let at_end = self.parts.get(self.part).is_none_or(|p| self.pos >= p.len());
|
||||
if at_end && self.settle() {
|
||||
high = self.u8()? & 1 != 0;
|
||||
}
|
||||
units.push(if high { self.u16()? } else { u16::from(self.u8()?) });
|
||||
}
|
||||
Ok(String::from_utf16_lossy(&units))
|
||||
}
|
||||
|
||||
/// ShortXLUnicodeString: 1-byte count, flags, characters.
|
||||
pub fn short_string(&mut self) -> Result<String, IoError> {
|
||||
let cch = usize::from(self.u8()?);
|
||||
let high = self.u8()? & 1 != 0;
|
||||
self.chars(cch, high)
|
||||
}
|
||||
|
||||
/// XLUnicodeString: 2-byte count, flags, characters.
|
||||
pub fn string(&mut self) -> Result<String, IoError> {
|
||||
let cch = usize::from(self.u16()?);
|
||||
let high = self.u8()? & 1 != 0;
|
||||
self.chars(cch, high)
|
||||
}
|
||||
|
||||
/// XLUnicodeRichExtendedString: the text; formatting runs and phonetic data are skipped.
|
||||
pub fn rich_string(&mut self) -> Result<String, IoError> {
|
||||
let cch = usize::from(self.u16()?);
|
||||
let flags = self.u8()?;
|
||||
let runs = if flags & 0x08 != 0 { usize::from(self.u16()?) } else { 0 };
|
||||
let ext = if flags & 0x04 != 0 { self.u32()? as usize } else { 0 };
|
||||
let text = self.chars(cch, flags & 1 != 0)?;
|
||||
let extra = runs.saturating_mul(4).saturating_add(ext);
|
||||
if extra > self.remaining() {
|
||||
return Err(invalid("string formatting runs past the end of the record"));
|
||||
}
|
||||
self.skip(extra)?;
|
||||
Ok(text)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strings_continue_with_a_new_compression_flag() {
|
||||
// "ab" compressed, then "ç" and "d" as UTF-16 in a Continue part.
|
||||
let first: &[u8] = &[4, 0, 0, b'a', b'b'];
|
||||
let second: &[u8] = &[1, 0xE7, 0, b'd', 0];
|
||||
let parts = [first, second];
|
||||
let mut c = Cursor::new(&parts);
|
||||
assert_eq!(c.rich_string().unwrap(), "abçd");
|
||||
assert_eq!(c.remaining(), 0);
|
||||
let mut c = Cursor::new(&parts);
|
||||
assert!(c.chars(MAX_CHARS + 1, false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remaining_counts_every_part_wherever_the_cursor_is() {
|
||||
let parts: [&[u8]; 4] = [&[1, 2, 3], &[2, 3, 4, 5], &[3, 4, 5, 6, 7], &[4]];
|
||||
let mut c = Cursor::new(&parts);
|
||||
assert_eq!(c.remaining(), 13);
|
||||
for b in 1..=3u64 {
|
||||
assert_eq!(u64::from(c.u8().unwrap()), b, "part 0 in order");
|
||||
}
|
||||
assert_eq!(c.remaining(), 10, "after the first part is used up");
|
||||
for _ in 0..4 {
|
||||
c.u8().unwrap();
|
||||
}
|
||||
assert_eq!(c.remaining(), 6, "into the Continue parts");
|
||||
let mut c = Cursor::new(&parts);
|
||||
assert_eq!(u64::from(c.u8().unwrap()), 1);
|
||||
assert_eq!(c.rest().len(), 12);
|
||||
assert_eq!(c.remaining(), 0);
|
||||
}
|
||||
}
|
||||
@@ -1,406 +0,0 @@
|
||||
//! Cell formatting in a BIFF8 Workbook stream, from [MS-XLS]: Font (2.4.122), XF (2.4.353) with
|
||||
//! CellXF (2.5.91), Palette (2.4.188) and Style (2.4.269). Every XF is
|
||||
//! decoded on its own: a cell XF stores its complete formatting (the fAtr* bits only say whether
|
||||
//! later changes to the parent style propagate), so nothing is inherited from `ixfParent`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use gridcraft_model::style::Protection;
|
||||
use gridcraft_model::{
|
||||
Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, StyleId, StyleTable, Underline, VAlign,
|
||||
VertAlign, Workbook,
|
||||
};
|
||||
|
||||
use super::records::Cursor;
|
||||
use crate::tables::{INDEXED, builtin_format};
|
||||
|
||||
/// Font records kept at most ([MS-XLS] FontIndex allows indexes up to 1022).
|
||||
const MAX_FONTS: usize = 1024;
|
||||
/// Style records kept at most.
|
||||
const MAX_STYLES: usize = 65_536;
|
||||
/// Characters kept from a style name (Excel allows 255).
|
||||
const MAX_STYLE_NAME: usize = 255;
|
||||
|
||||
/// The automatic font colour (IcvFont 0x7FFF) and the system foreground/background colours.
|
||||
const ICV_FONT_AUTO: u16 = 0x7FFF;
|
||||
const ICV_DEFAULT_FG: u16 = 0x40;
|
||||
const ICV_DEFAULT_BG: u16 = 0x41;
|
||||
|
||||
/// One XF record, decoded as far as its bytes allow.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(super) struct Xf {
|
||||
ifnt: u16,
|
||||
ifmt: u16,
|
||||
flags: u16,
|
||||
/// The CellXF/StyleXF data: 14 bytes when complete.
|
||||
data: [u8; 14],
|
||||
/// Whether all 20 bytes were present.
|
||||
complete: bool,
|
||||
}
|
||||
|
||||
/// A Style record: the XF it names and its name.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct NamedStyle {
|
||||
ixf: u16,
|
||||
/// `Some(id)` for a built-in style (BuiltInStyle.istyBuiltIn), `None` for a user style.
|
||||
builtin: Option<u8>,
|
||||
/// A user style's name (empty for built-in styles).
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// Formatting records collected from the globals substream.
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct StyleRecords {
|
||||
fonts: Vec<Font>,
|
||||
font_colors: Vec<u16>,
|
||||
xfs: Vec<Xf>,
|
||||
/// Palette entries for colour indexes 8 and up (RRGGBB), when a Palette record is present.
|
||||
palette: Option<Vec<u32>>,
|
||||
styles: Vec<NamedStyle>,
|
||||
/// Records that were shorter than their structure; the parts present were used.
|
||||
pub(super) short: bool,
|
||||
}
|
||||
|
||||
impl StyleRecords {
|
||||
/// A Font record. The colour is resolved later, once the palette is known.
|
||||
pub(super) fn font(&mut self, c: &mut Cursor<'_, '_>) {
|
||||
if self.fonts.len() >= MAX_FONTS {
|
||||
return;
|
||||
}
|
||||
match read_font(c) {
|
||||
Some((font, icv)) => {
|
||||
self.fonts.push(font);
|
||||
self.font_colors.push(icv);
|
||||
}
|
||||
None => {
|
||||
// Keep the numbering of the fonts after it.
|
||||
self.short = true;
|
||||
self.fonts.push(Font::default());
|
||||
self.font_colors.push(ICV_FONT_AUTO);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An XF record. A short record keeps the fields it has.
|
||||
pub(super) fn xf(&mut self, c: &mut Cursor<'_, '_>, max: usize) {
|
||||
if self.xfs.len() >= max {
|
||||
return;
|
||||
}
|
||||
let mut xf = Xf::default();
|
||||
let head = (|| Some((c.u16().ok()?, c.u16().ok()?, c.u16().ok()?)))();
|
||||
match head {
|
||||
Some((ifnt, ifmt, flags)) => {
|
||||
xf.ifnt = ifnt;
|
||||
xf.ifmt = ifmt;
|
||||
xf.flags = flags;
|
||||
xf.complete = true;
|
||||
for b in xf.data.iter_mut() {
|
||||
match c.u8() {
|
||||
Ok(v) => *b = v,
|
||||
Err(_) => {
|
||||
xf.complete = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Not even the font and format: an unformatted, locked cell.
|
||||
xf.flags = 1;
|
||||
}
|
||||
}
|
||||
if !xf.complete {
|
||||
self.short = true;
|
||||
}
|
||||
self.xfs.push(xf);
|
||||
}
|
||||
|
||||
/// A Palette record: colours for indexes 8 onwards.
|
||||
pub(super) fn palette(&mut self, c: &mut Cursor<'_, '_>) {
|
||||
let Ok(ccv) = c.u16() else {
|
||||
self.short = true;
|
||||
return;
|
||||
};
|
||||
let n = usize::from(ccv).min(56);
|
||||
if usize::from(ccv) > c.remaining() / 4 {
|
||||
self.short = true;
|
||||
}
|
||||
let mut colors = Vec::with_capacity(n);
|
||||
for _ in 0..n {
|
||||
let (Ok(r), Ok(g), Ok(b), Ok(_)) = (c.u8(), c.u8(), c.u8(), c.u8()) else { break };
|
||||
colors.push((u32::from(r) << 16) | (u32::from(g) << 8) | u32::from(b));
|
||||
}
|
||||
self.palette = Some(colors);
|
||||
}
|
||||
|
||||
/// A Style record.
|
||||
pub(super) fn style(&mut self, c: &mut Cursor<'_, '_>) {
|
||||
if self.styles.len() >= MAX_STYLES {
|
||||
return;
|
||||
}
|
||||
let Ok(word) = c.u16() else {
|
||||
self.short = true;
|
||||
return;
|
||||
};
|
||||
let ixf = word & 0x0FFF;
|
||||
if word & 0x8000 != 0 {
|
||||
let Ok(id) = c.u8() else {
|
||||
self.short = true;
|
||||
return;
|
||||
};
|
||||
// BuiltInStyle: the style's number (0 is Normal) and outline level.
|
||||
self.styles.push(NamedStyle { ixf, builtin: Some(id), name: String::new() });
|
||||
} else {
|
||||
match c.string() {
|
||||
Ok(name) => {
|
||||
let name: String = name.chars().filter(|ch| *ch != '\0').take(MAX_STYLE_NAME).collect();
|
||||
if !name.is_empty() {
|
||||
self.styles.push(NamedStyle { ixf, builtin: None, name });
|
||||
}
|
||||
}
|
||||
Err(_) => self.short = true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The 64-entry colour table: the built-in eight, then the palette (or the default one).
|
||||
fn color_table(&self) -> [u32; 64] {
|
||||
let mut table = INDEXED;
|
||||
if let Some(p) = &self.palette {
|
||||
for (slot, c) in table.iter_mut().skip(8).zip(p.iter()) {
|
||||
*slot = *c;
|
||||
}
|
||||
}
|
||||
table
|
||||
}
|
||||
}
|
||||
|
||||
/// Font record fields up to the name; `None` if the record is too short for them.
|
||||
fn read_font(c: &mut Cursor<'_, '_>) -> Option<(Font, u16)> {
|
||||
let height = c.u16().ok()?;
|
||||
let flags = c.u16().ok()?;
|
||||
let icv = c.u16().ok()?;
|
||||
let weight = c.u16().ok()?;
|
||||
let script = c.u16().ok()?;
|
||||
let underline = c.u8().ok()?;
|
||||
let family = c.u8().ok()?;
|
||||
let charset = c.u8().ok()?;
|
||||
c.skip(1).ok()?; // reserved
|
||||
let name = c.short_string().ok()?;
|
||||
let mut f = Font::default();
|
||||
let name: String = name.chars().filter(|ch| *ch != '\0').take(255).collect();
|
||||
if !name.trim().is_empty() {
|
||||
f.name = name;
|
||||
}
|
||||
if height > 0 {
|
||||
f.size = (f32::from(height) / 20.0).clamp(1.0, 409.0);
|
||||
}
|
||||
f.italic = flags & 0x0002 != 0;
|
||||
f.strike = flags & 0x0008 != 0;
|
||||
// Weights run 100-1000; Excel writes 400 for normal and 700 for bold.
|
||||
f.bold = weight >= 600;
|
||||
f.vert = match script {
|
||||
1 => VertAlign::Superscript,
|
||||
2 => VertAlign::Subscript,
|
||||
_ => VertAlign::Baseline,
|
||||
};
|
||||
f.underline = match underline {
|
||||
0x01 => Underline::Single,
|
||||
0x02 => Underline::Double,
|
||||
0x21 => Underline::SingleAccounting,
|
||||
0x22 => Underline::DoubleAccounting,
|
||||
_ => Underline::None,
|
||||
};
|
||||
f.family = Some(family);
|
||||
f.charset = Some(charset);
|
||||
Some((f, icv))
|
||||
}
|
||||
|
||||
/// A colour index in an XF (IcvXF) or Font (IcvFont): 0-63 from the colour table, the system
|
||||
/// and automatic colours as automatic.
|
||||
fn color(icv: u16, table: &[u32; 64]) -> Color {
|
||||
match icv {
|
||||
ICV_DEFAULT_FG | ICV_DEFAULT_BG | ICV_FONT_AUTO => Color::Auto,
|
||||
_ => table.get(usize::from(icv)).map_or(Color::Auto, |c| Color::Rgb(*c)),
|
||||
}
|
||||
}
|
||||
|
||||
/// A border line style from its BIFF8 code (dgLeft and the like); unknown codes are no border.
|
||||
fn border_style(dg: u32) -> BorderStyle {
|
||||
match dg {
|
||||
1 => BorderStyle::Thin,
|
||||
2 => BorderStyle::Medium,
|
||||
3 => BorderStyle::Dashed,
|
||||
4 => BorderStyle::Dotted,
|
||||
5 => BorderStyle::Thick,
|
||||
6 => BorderStyle::Double,
|
||||
7 => BorderStyle::Hair,
|
||||
8 => BorderStyle::MediumDashed,
|
||||
9 => BorderStyle::DashDot,
|
||||
10 => BorderStyle::MediumDashDot,
|
||||
11 => BorderStyle::DashDotDot,
|
||||
12 => BorderStyle::MediumDashDotDot,
|
||||
13 => BorderStyle::SlantDashDot,
|
||||
_ => BorderStyle::None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A fill pattern from its BIFF8 code (fls); unknown codes are no pattern.
|
||||
fn pattern(fls: u16) -> PatternType {
|
||||
match fls {
|
||||
0x01 => PatternType::Solid,
|
||||
0x02 => PatternType::Gray50,
|
||||
0x03 => PatternType::Gray75,
|
||||
0x04 => PatternType::Gray25,
|
||||
0x05 => PatternType::DarkHorizontal,
|
||||
0x06 => PatternType::DarkVertical,
|
||||
0x07 => PatternType::DarkDown,
|
||||
0x08 => PatternType::DarkUp,
|
||||
0x09 => PatternType::DarkGrid,
|
||||
0x0A => PatternType::DarkTrellis,
|
||||
0x0B => PatternType::LightHorizontal,
|
||||
0x0C => PatternType::LightVertical,
|
||||
0x0D => PatternType::LightDown,
|
||||
0x0E => PatternType::LightUp,
|
||||
0x0F => PatternType::LightGrid,
|
||||
0x10 => PatternType::LightTrellis,
|
||||
0x11 => PatternType::Gray125,
|
||||
0x12 => PatternType::Gray0625,
|
||||
_ => PatternType::None,
|
||||
}
|
||||
}
|
||||
|
||||
/// One border side: its style and colour; a side without a style has no colour.
|
||||
fn line(dg: u32, icv: u32, table: &[u32; 64]) -> BorderLine {
|
||||
let style = border_style(dg);
|
||||
if style == BorderStyle::None {
|
||||
return BorderLine::default();
|
||||
}
|
||||
// Colour 0 means "not specified": automatic.
|
||||
let color = if icv == 0 { Color::Auto } else { color(icv as u16, table) };
|
||||
BorderLine { style, color }
|
||||
}
|
||||
|
||||
/// The font an XF names (FontIndex: index 4 is never written, so 5 and up count from one).
|
||||
fn xf_font(ifnt: u16, fonts: &[Font]) -> Font {
|
||||
let i = match ifnt {
|
||||
0..=3 => usize::from(ifnt),
|
||||
4 => return fonts.first().cloned().unwrap_or_default(),
|
||||
_ => usize::from(ifnt) - 1,
|
||||
};
|
||||
fonts.get(i).or_else(|| fonts.first()).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The number format an XF names: a custom format, else a built-in one, else General.
|
||||
fn num_fmt(ifmt: u16, formats: &HashMap<u16, String>) -> NumFmt {
|
||||
let code = formats.get(&ifmt).map(String::as_str).or_else(|| builtin_format(u32::from(ifmt))).unwrap_or("General");
|
||||
if code.eq_ignore_ascii_case("General") { NumFmt::default() } else { NumFmt::new(code) }
|
||||
}
|
||||
|
||||
/// The model style of one XF.
|
||||
fn xf_style(xf: &Xf, fonts: &[Font], formats: &HashMap<u16, String>, table: &[u32; 64]) -> Style {
|
||||
let mut s = Style {
|
||||
font: xf_font(xf.ifnt, fonts),
|
||||
num_fmt: num_fmt(xf.ifmt, formats),
|
||||
protection: Protection { locked: xf.flags & 0x0001 != 0, hidden: xf.flags & 0x0002 != 0 },
|
||||
..Style::default()
|
||||
};
|
||||
if !xf.complete {
|
||||
return s;
|
||||
}
|
||||
let d = &xf.data;
|
||||
let byte = |i: usize| d.get(i).copied().unwrap_or(0);
|
||||
let word32 = |i: usize| u32::from_le_bytes([byte(i), byte(i + 1), byte(i + 2), byte(i + 3)]);
|
||||
let (a0, rot, a2) = (byte(0), byte(1), byte(2));
|
||||
s.align = Alignment {
|
||||
h: match a0 & 0x07 {
|
||||
1 => HAlign::Left,
|
||||
2 => HAlign::Center,
|
||||
3 => HAlign::Right,
|
||||
4 => HAlign::Fill,
|
||||
5 => HAlign::Justify,
|
||||
6 => HAlign::CenterAcross,
|
||||
7 => HAlign::Distributed,
|
||||
_ => HAlign::General,
|
||||
},
|
||||
wrap: a0 & 0x08 != 0,
|
||||
v: match (a0 >> 4) & 0x07 {
|
||||
0 => VAlign::Top,
|
||||
1 => VAlign::Center,
|
||||
3 => VAlign::Justify,
|
||||
4 => VAlign::Distributed,
|
||||
_ => VAlign::Bottom,
|
||||
},
|
||||
rotation: match rot {
|
||||
0..=90 => i16::from(rot),
|
||||
91..=180 => -(i16::from(rot) - 90),
|
||||
255 => 255,
|
||||
_ => 0,
|
||||
},
|
||||
indent: a2 & 0x0F,
|
||||
shrink: a2 & 0x10 != 0,
|
||||
reading_order: None,
|
||||
};
|
||||
let b1 = word32(4);
|
||||
let b2 = word32(8);
|
||||
let mut borders = Borders {
|
||||
left: line(b1 & 0x0F, (b1 >> 16) & 0x7F, table),
|
||||
right: line((b1 >> 4) & 0x0F, (b1 >> 23) & 0x7F, table),
|
||||
top: line((b1 >> 8) & 0x0F, b2 & 0x7F, table),
|
||||
bottom: line((b1 >> 12) & 0x0F, (b2 >> 7) & 0x7F, table),
|
||||
..Borders::default()
|
||||
};
|
||||
let diag = line((b2 >> 21) & 0x0F, (b2 >> 14) & 0x7F, table);
|
||||
let which = (b1 >> 30) & 0x03;
|
||||
if which & 1 != 0 {
|
||||
borders.diag_down = diag;
|
||||
}
|
||||
if which & 2 != 0 {
|
||||
borders.diag_up = diag;
|
||||
}
|
||||
s.borders = borders;
|
||||
let fill = u16::from_le_bytes([byte(12), byte(13)]);
|
||||
let fls = ((b2 >> 26) & 0x3F) as u16;
|
||||
let fg = color(fill & 0x7F, table);
|
||||
let bg = color((fill >> 7) & 0x7F, table);
|
||||
s.fill = match pattern(fls) {
|
||||
PatternType::None => Fill::default(),
|
||||
PatternType::Solid => Fill::solid(fg),
|
||||
p => Fill { pattern: p, fg, bg },
|
||||
};
|
||||
s
|
||||
}
|
||||
|
||||
/// Imports the formatting: sets the workbook's default (Normal) font and named cell styles, and
|
||||
/// returns the model style of every XF in file order.
|
||||
pub(super) fn build(r: &StyleRecords, formats: &HashMap<u16, String>, wb: &mut Workbook) -> Vec<StyleId> {
|
||||
let table = r.color_table();
|
||||
let fonts: Vec<Font> = r.fonts.iter().zip(r.font_colors.iter()).map(|(f, icv)| Font { color: color(*icv, &table), ..f.clone() }).collect();
|
||||
// The Normal style's font is what every unformatted cell shows.
|
||||
let normal_xf = r.styles.iter().find(|s| s.builtin == Some(0)).map_or(0, |s| usize::from(s.ixf));
|
||||
let normal_font = r.xfs.get(normal_xf).map(|xf| xf_font(xf.ifnt, &fonts)).or_else(|| fonts.first().cloned());
|
||||
if let Some(font) = normal_font {
|
||||
wb.styles = StyleTable::with_default(Style { font, ..Style::default() });
|
||||
}
|
||||
let out = r.xfs.iter().map(|xf| wb.styles.intern(xf_style(xf, &fonts, formats, &table))).collect();
|
||||
// User-defined named styles. Excel 2007 and later write every built-in style into each .xls
|
||||
// (about fifty, their colours approximated to the palette); GridCraft defines those itself.
|
||||
let mut named: Vec<(String, Style)> = Vec::new();
|
||||
// Names are compared ASCII-case-insensitively; a set keeps a hostile file's many names linear.
|
||||
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::from(["normal".to_string()]);
|
||||
for ns in &r.styles {
|
||||
if ns.builtin.is_some() {
|
||||
continue;
|
||||
}
|
||||
let key = ns.name.to_ascii_lowercase();
|
||||
if seen.contains(&key) {
|
||||
continue;
|
||||
}
|
||||
if let Some(xf) = r.xfs.get(usize::from(ns.ixf)) {
|
||||
seen.insert(key);
|
||||
named.push((ns.name.clone(), xf_style(xf, &fonts, formats, &table)));
|
||||
}
|
||||
}
|
||||
wb.cell_styles = named;
|
||||
out
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity
|
||||
|
||||
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-03 · **Change:** minor (form controls: buttons, scroll bars and spin buttons read and written; earlier: a web page's first table opens as a sheet, also when the page is named `.xls`; earlier: `workbookPr filterPrivacy` kept, and honoured on save; earlier: a workbook's R1C1 reference style, `calcPr refMode`, is kept through open and save; earlier: XLSX page margins are written as their decimals, `0.3` rather than `0.30000001192092896`; earlier: Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-12 · **Change:** minor (.xls data validation is read; earlier: .xls is read by the shared craft-xls crate: the workbook opens on the sheet it was saved on, and rich-text (RString) cells are read; earlier: form controls: buttons, scroll bars and spin buttons read and written; earlier: a web page's first table opens as a sheet, also when the page is named `.xls`; earlier: `workbookPr filterPrivacy` kept, and honoured on save; earlier: a workbook's R1C1 reference style, `calcPr refMode`, is kept through open and save; earlier: XLSX page margins are written as their decimals, `0.3` rather than `0.30000001192092896`; earlier: Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
|
||||
Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests.
|
||||
Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h**
|
||||
@@ -92,7 +92,7 @@ past the parser's limit, and a wrapper inside the text. **Saving writes the real
|
||||
| `.xlsm` Macro-Enabled Workbook | R/W | yes | yes, macros kept, not run | The VBA project is kept byte-for-byte and written back only into `.xlsm`/`.xltm` (#6); macros never run. Saved with the macro-enabled workbook content type | `roundtrip.rs` (`vba_project_roundtrip`, `package_kind_sets_workbook_content_type`), engine `io.rs` (`macros_are_saved_only_into_xlsm`) |
|
||||
| `.xltx` / `.xltm` templates | R/W | yes | partial | Saved with the template (and macro-enabled template) content type, chosen by extension; `.xltm` keeps a VBA project, `.xltx` drops it; "new from template" behaviour missing | `roundtrip.rs` (`package_kind_sets_workbook_content_type`), engine `io.rs` |
|
||||
| `.xlsb` Binary Workbook | R/W | **values only** (#101) | no | Names, visibility, numbers, text, booleans, errors, merges, cached formula results; no formulas, formatting, charts, names | `xlsb_tests.rs` |
|
||||
| `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), cell formatting (fonts, solid and pattern fills, borders with diagonals, alignment with rotation, indent, wrap and shrink, locked and hidden, the custom colour palette, the Normal font as the workbook default, user-defined cell styles), 1904 date system, merges, column widths, row heights, defined names, print area and titles, calculation settings (mode, iteration with its maximum iterations and change, precision as displayed), sheet display options (right-to-left, gridlines, headings, zero values, formulas; a truncated WINDOW2 is ignored). Every formula except a Ctrl+Shift+Enter array is a legacy one and opens with Excel's implicit `@`, as an `.xlsx` formula without `cm` does (#410); Ctrl+Shift+Enter arrays, one cell or several, stay arrays, and saving as `.xlsx` writes the formulas without `cm`. Not read: exact colours beyond the 56-colour palette (XFExt: theme colours and tints show as the palette colour Excel stored next to them), rich-text runs inside a cell, charts, pictures, comments, CF, validation, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs`, `xls_style_tests.rs` |
|
||||
| `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), cell formatting (fonts, solid and pattern fills, borders with diagonals, alignment with rotation, indent, wrap and shrink, locked and hidden, the custom colour palette, the Normal font as the workbook default, user-defined cell styles), 1904 date system, merges, column widths, row heights, defined names, print area and titles, calculation settings (mode, iteration with its maximum iterations and change, precision as displayed), data validation rules, sheet display options (right-to-left, gridlines, headings, zero values, formulas; a truncated WINDOW2 is ignored). Every formula except a Ctrl+Shift+Enter array is a legacy one and opens with Excel's implicit `@`, as an `.xlsx` formula without `cm` does (#410); Ctrl+Shift+Enter arrays, one cell or several, stay arrays, and saving as `.xlsx` writes the formulas without `cm`. Not read: exact colours beyond the 56-colour palette (XFExt: theme colours and tints show as the palette colour Excel stored next to them), rich-text runs inside a cell, charts, pictures, comments, CF, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs`, `xls_style_tests.rs`, `xls_dv_tests.rs` |
|
||||
| `.xlt` Excel 97-2003 template | R/W | yes (read as `.xls`) | no | | — |
|
||||
| `.xlam` / `.xla` add-ins | R/W | no | no | VBA; out of scope | — |
|
||||
| `.xml` XML Spreadsheet 2003 | R/W | no | no | Simple to support | — |
|
||||
@@ -161,6 +161,8 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-12 | minor | `.xls` files open with their data validation (drop-down lists, input messages, error alerts and the rules), read by craft-xls and mapped as the `.xlsx` reader maps the same rules. A rule Excel would refuse, or one past 65,536 in a workbook, is skipped with a warning (#3634, from #3636) |
|
||||
| 2026-11-12 | minor | .xls import reads the file with craft-xls (storytold/craft-libs), the reader that moved there from this repository; GridCraft keeps the mapping onto its model. The workbook now opens on the sheet it was saved on (WINDOW1 `itabCur`), falling back to the first visible sheet as before (from #3551), and cells saved as RString records (text with formatting runs) are read as text instead of skipped |
|
||||
| 2026-11-10 | minor | `.xls` import: every formula except a Ctrl+Shift+Enter array is read as a legacy one with Excel's implicit `@` (a title row `=Other!A1:G1` shows its first cell instead of `#SPILL!`), one-cell Ctrl+Shift+Enter arrays stay arrays, and `@` of a 2-D range meets the formula's row and column (#410, PR #1367) |
|
||||
| 2026-11-02 | minor | XLSX: buttons, scroll bars and spin buttons read and written (control properties parts, `<controls>`, VML `x:ClientData`); other form controls stay with pass-through; a button's command, fixed input or output cell travels in a hidden defined name and comes back only while the file still assigns the button's macro (or none, for a command) |
|
||||
| 2026-11-03 | minor | SYLK (`.slk`) import: values, R1C1 formulas as A1 (shared formulas too) with cached results, number formats and column widths; import only (#1481) |
|
||||
|
||||
+1
-1
@@ -155,7 +155,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
|
||||
### 17. Insert objects (F)
|
||||
- Ink strokes now survive XLSX save/reopen as editable polylines. Inserted icons still lose their
|
||||
identity/artwork on save (#551); native Office InkML import is not implemented.
|
||||
- SmartArt, WordArt, signature line, objects, 3-D models, screenshot; 8 shape kinds vs ~170;
|
||||
- SmartArt, signature line, objects, 3-D models, screenshot; 8 shape kinds vs ~170;
|
||||
pictures in cells (#61, PR #105). Equations exist (LaTeX source with a live preview, Office Math in xlsx); there is
|
||||
no structured (WYSIWYG) equation editing and no ink equations. **Estimate:** 25–40 h.
|
||||
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
|
||||
Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md).
|
||||
|
||||
**Overall: 281 / 296 commands implemented (94.9%).**
|
||||
**Overall: 282 / 296 commands implemented (95.3%).**
|
||||
|
||||
| Tab | Implemented | Total | Parity |
|
||||
|---|---|---|---|
|
||||
| File | 11 | 11 | 100% |
|
||||
| Home | 94 | 94 | 100% |
|
||||
| Insert | 18 | 25 | 72% |
|
||||
| Insert | 19 | 25 | 76% |
|
||||
| Draw | 4 | 4 | 100% |
|
||||
| Page Layout | 23 | 23 | 100% |
|
||||
| Formulas | 23 | 23 | 100% |
|
||||
@@ -140,7 +140,7 @@ Implemented:
|
||||
- `edit.goToSpecial` Go To Special (Editing)
|
||||
- `home.addIns` Add-ins (Add-ins)
|
||||
|
||||
## Insert (18 / 25, 72%)
|
||||
## Insert (19 / 25, 76%)
|
||||
|
||||
Implemented:
|
||||
|
||||
@@ -159,6 +159,7 @@ Implemented:
|
||||
- `review.newComment` Comment (Comments)
|
||||
- `insert.textBox` Text Box (Text)
|
||||
- `pageLayout.headerFooter` Header & Footer (Text)
|
||||
- `insert.wordArt` Decorative Text (Text)
|
||||
- `insert.equation` Equation (Symbols)
|
||||
- `insert.symbol` Symbol (Symbols)
|
||||
- `insert.checkbox` Checkbox (Cell Controls)
|
||||
@@ -169,7 +170,6 @@ Missing:
|
||||
- `insert.screenshot` Screenshot (Illustrations)
|
||||
- `insert.map` Maps (Charts)
|
||||
- `insert.timeline` Timeline (Filters)
|
||||
- `insert.wordArt` Decorative Text (Text)
|
||||
- `insert.signatureLine` Signature Line (Text)
|
||||
- `insert.object` Object (Text)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user