mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-14 09:55:04 -06:00
Compare commits
150
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45ee4eceed | ||
|
|
fdba683246 | ||
|
|
10849d5003 | ||
|
|
494b830240 | ||
|
|
40783ab184 | ||
|
|
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 | ||
|
|
0c5b1e85e6 | ||
|
|
7f6a79fd98 | ||
|
|
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
+160
-34
@@ -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]]
|
||||
@@ -2023,7 +2126,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft"
|
||||
version = "0.19.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -2051,7 +2154,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-calc"
|
||||
version = "0.19.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
@@ -2063,7 +2166,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-chart"
|
||||
version = "0.19.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"gridcraft-calc",
|
||||
"gridcraft-core",
|
||||
@@ -2074,7 +2177,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-cli"
|
||||
version = "0.19.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"gridcraft-mcp",
|
||||
@@ -2087,7 +2190,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-core"
|
||||
version = "0.19.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"js-sys",
|
||||
@@ -2098,8 +2201,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-engine"
|
||||
version = "0.19.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"craft-csv",
|
||||
"gridcraft-calc",
|
||||
"gridcraft-chart",
|
||||
"gridcraft-core",
|
||||
@@ -2121,7 +2225,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-formula"
|
||||
version = "0.19.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"serde",
|
||||
@@ -2130,7 +2234,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-functions"
|
||||
version = "0.19.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"regex",
|
||||
@@ -2138,7 +2242,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-math"
|
||||
version = "0.19.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"ratex-font",
|
||||
"ratex-layout",
|
||||
@@ -2148,7 +2252,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-mcp"
|
||||
version = "0.19.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"log",
|
||||
@@ -2157,7 +2261,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-model"
|
||||
version = "0.19.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
@@ -2170,18 +2274,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-numfmt"
|
||||
version = "0.19.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-pdf"
|
||||
version = "0.19.0"
|
||||
version = "0.19.1"
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-sandbox"
|
||||
version = "0.19.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"landlock",
|
||||
"libc",
|
||||
@@ -2192,7 +2296,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-ui-egui"
|
||||
version = "0.19.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
@@ -2219,7 +2323,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-web"
|
||||
version = "0.19.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"craft-web-api",
|
||||
"craft-web-host",
|
||||
@@ -2238,16 +2342,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-xlsx"
|
||||
version = "0.19.0"
|
||||
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",
|
||||
@@ -6710,7 +6836,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.19.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"serde_json",
|
||||
|
||||
+16
-8
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.19.0"
|
||||
version = "0.19.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -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"] }
|
||||
|
||||
@@ -340,12 +340,16 @@ fn main() -> eframe::Result<()> {
|
||||
for f in &files {
|
||||
app.open_path(f);
|
||||
}
|
||||
// Linux/BSD: Excel's layout — no window-manager title bar above ours; our title bar carries the
|
||||
// window controls. `GRIDCRAFT_SYSTEM_TITLEBAR=1` keeps the system frame.
|
||||
app.window_controls = cfg!(not(any(target_os = "macos", target_os = "windows"))) && std::env::var_os("GRIDCRAFT_SYSTEM_TITLEBAR").is_none();
|
||||
let mut viewport = egui::ViewportBuilder::default()
|
||||
.with_title("GridCraft")
|
||||
.with_inner_size([1440.0, 900.0])
|
||||
.with_min_inner_size([640.0, 420.0])
|
||||
.with_app_id("ai.storyteller.gridcraft")
|
||||
.with_drag_and_drop(true);
|
||||
.with_drag_and_drop(true)
|
||||
.with_decorations(!app.window_controls);
|
||||
if cfg!(target_os = "macos") {
|
||||
viewport = viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false);
|
||||
}
|
||||
|
||||
+137
-46
@@ -27,62 +27,153 @@ pub fn resized_sum_range(sum: RangeRef, height: u32, width: u32) -> RangeRef {
|
||||
RangeRef::new(sum.start, CellRef::new(row, col))
|
||||
}
|
||||
|
||||
/// Most expression nodes [`precedents`] visits before giving up and marking the formula dynamic.
|
||||
const MAX_PRECEDENT_NODES: usize = 1_000_000;
|
||||
|
||||
/// Collects the static references of an expression with their sheets resolved; `dynamic` is
|
||||
/// set when the formula uses INDIRECT/OFFSET/names/tables whose targets can change.
|
||||
///
|
||||
/// Every node is visited once (#2183: range-operator chains used to re-walk shared operands,
|
||||
/// costing 2^k for k operands), with an explicit stack, so a flat chain as long as Excel allows
|
||||
/// (`1+1+…`, about 4,000 levels deep) never recurses. Past [`MAX_PRECEDENT_NODES`] the result
|
||||
/// is conservative: `dynamic` is set so the formula recalculates every time.
|
||||
pub fn precedents(wb: &Workbook, sheet: usize, e: &Expr) -> (Vec<Area>, bool) {
|
||||
let mut out = Vec::new();
|
||||
let mut dynamic = false;
|
||||
let push = |out: &mut Vec<Area>, r: &Reference, range: RangeRef| match &r.sheet {
|
||||
SheetSel::Current => out.push(Area { sheet, range }),
|
||||
SheetSel::Named(n) => {
|
||||
if let Some(i) = wb.sheet_index(n) {
|
||||
out.push(Area { sheet: i, range });
|
||||
}
|
||||
let mut c = Precedents { wb, sheet, out: Vec::new(), dynamic: false, boxes: Vec::new() };
|
||||
c.collect(e);
|
||||
(c.out, c.dynamic)
|
||||
}
|
||||
|
||||
/// Grows the per-sheet bounding box in `boxes` to cover `area`.
|
||||
fn union_box(boxes: &mut Vec<Area>, area: Area) {
|
||||
match boxes.iter_mut().find(|x| x.sheet == area.sheet) {
|
||||
Some(x) => x.range = x.range.union(&area.range),
|
||||
None => boxes.push(area),
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry of [`Precedents::collect`]'s explicit stack. `boxes` indexes
|
||||
/// [`Precedents::boxes`]: the bounding boxes of the innermost enclosing range operator, if any.
|
||||
enum PrecedentStep<'e> {
|
||||
/// Visit an expression and queue its children.
|
||||
Visit(&'e Expr, Option<usize>),
|
||||
/// Both operands of a range operator have been visited: record the bounding boxes they
|
||||
/// collected in `inner`, growing the `outer` operator's boxes too.
|
||||
Close { inner: usize, outer: Option<usize> },
|
||||
}
|
||||
|
||||
struct Precedents<'w> {
|
||||
wb: &'w Workbook,
|
||||
sheet: usize,
|
||||
out: Vec<Area>,
|
||||
dynamic: bool,
|
||||
/// Per-sheet bounding boxes, one list per range operator (`A1:INDEX(…)`) met so far.
|
||||
boxes: Vec<Vec<Area>>,
|
||||
}
|
||||
|
||||
impl Precedents<'_> {
|
||||
/// Records `area`, also growing the bounding boxes of the enclosing range operator, if any.
|
||||
fn add(&mut self, area: Area, boxes: Option<usize>) {
|
||||
if let Some(b) = boxes.and_then(|i| self.boxes.get_mut(i)) {
|
||||
union_box(b, area);
|
||||
}
|
||||
SheetSel::Span(a, b) => {
|
||||
if let (Some(i), Some(j)) = (wb.sheet_index(a), wb.sheet_index(b)) {
|
||||
for s in i.min(j)..=i.max(j) {
|
||||
out.push(Area { sheet: s, range });
|
||||
self.out.push(area);
|
||||
}
|
||||
|
||||
fn push(&mut self, r: &Reference, range: RangeRef, boxes: Option<usize>) {
|
||||
match &r.sheet {
|
||||
SheetSel::Current => self.add(Area { sheet: self.sheet, range }, boxes),
|
||||
SheetSel::Named(n) => {
|
||||
if let Some(i) = self.wb.sheet_index(n) {
|
||||
self.add(Area { sheet: i, range }, boxes);
|
||||
}
|
||||
}
|
||||
SheetSel::Span(a, b) => {
|
||||
if let (Some(i), Some(j)) = (self.wb.sheet_index(a), self.wb.sheet_index(b)) {
|
||||
for s in i.min(j)..=i.max(j) {
|
||||
self.add(Area { sheet: s, range }, boxes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
e.walk(&mut |x| match x {
|
||||
Expr::Ref(r) => push(&mut out, r, r.range()),
|
||||
// The cells SUMIF/AVERAGEIF really read from the sum range (see `resized_sum_range`); when
|
||||
// the criteria range's size isn't known statically, recalculate the formula every time.
|
||||
Expr::Call(n, args) if resizes_sum_range(n, args.len()) => match (args.first(), args.get(2)) {
|
||||
(Some(Expr::Ref(c)), Some(Expr::Ref(s))) => {
|
||||
let (crit, sum) = (c.range(), s.range());
|
||||
push(&mut out, s, resized_sum_range(sum, crit.height(), crit.width()));
|
||||
}
|
||||
(_, Some(Expr::Number(_) | Expr::Text(_) | Expr::Bool(_) | Expr::Error(_) | Expr::Array(_) | Expr::Missing)) => {}
|
||||
_ => dynamic = true,
|
||||
},
|
||||
Expr::Call(n, _)
|
||||
if matches!(n.as_str(), "INDIRECT" | "OFFSET" | "NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" | "CELL" | "INFO") =>
|
||||
{
|
||||
dynamic = true
|
||||
}
|
||||
Expr::Name(_) | Expr::Struct(_) => dynamic = true,
|
||||
Expr::Call(n, _) if gridcraft_functions::lookup(n).is_none() && !is_special(n) => dynamic = true,
|
||||
// A range ending in a function's result (`A1:INDEX(B1:B9,n)`) covers the cells between
|
||||
// the references inside it, not only those references.
|
||||
Expr::Binary(BinOp::Range, a, b) if !matches!((&**a, &**b), (Expr::Ref(_), Expr::Ref(_))) => {
|
||||
let mut inner = precedents(wb, sheet, a).0;
|
||||
inner.extend(precedents(wb, sheet, b).0);
|
||||
let mut boxes: Vec<Area> = Vec::new();
|
||||
for area in inner {
|
||||
match boxes.iter_mut().find(|x| x.sheet == area.sheet) {
|
||||
Some(x) => x.range = x.range.union(&area.range),
|
||||
None => boxes.push(area),
|
||||
}
|
||||
|
||||
/// Visits every node of `root` once, parents before children, left to right. Areas found
|
||||
/// inside a range operator (`A1:INDEX(…)`) also grow that operator's per-sheet bounding boxes.
|
||||
fn collect(&mut self, root: &Expr) {
|
||||
let mut pending = vec![PrecedentStep::Visit(root, None)];
|
||||
let mut nodes = 0usize;
|
||||
while let Some(step) = pending.pop() {
|
||||
let (x, boxes) = match step {
|
||||
PrecedentStep::Visit(x, boxes) => (x, boxes),
|
||||
PrecedentStep::Close { inner, outer } => {
|
||||
let areas = self.boxes.get_mut(inner).map(std::mem::take).unwrap_or_default();
|
||||
for area in areas {
|
||||
self.add(area, outer);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
nodes = nodes.saturating_add(1);
|
||||
if nodes > MAX_PRECEDENT_NODES {
|
||||
self.dynamic = true;
|
||||
return;
|
||||
}
|
||||
out.extend(boxes);
|
||||
self.visit(x, boxes, &mut pending);
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
(out, dynamic)
|
||||
}
|
||||
|
||||
/// Handles one node for [`Precedents::collect`], queueing the children still to visit.
|
||||
fn visit<'e>(&mut self, x: &'e Expr, boxes: Option<usize>, pending: &mut Vec<PrecedentStep<'e>>) {
|
||||
match x {
|
||||
Expr::Ref(r) => self.push(r, r.range(), boxes),
|
||||
// The cells SUMIF/AVERAGEIF really read from the sum range (see `resized_sum_range`); when
|
||||
// the criteria range's size isn't known statically, recalculate the formula every time.
|
||||
Expr::Call(n, args) if resizes_sum_range(n, args.len()) => match (args.first(), args.get(2)) {
|
||||
(Some(Expr::Ref(c)), Some(Expr::Ref(s))) => {
|
||||
let (crit, sum) = (c.range(), s.range());
|
||||
self.push(s, resized_sum_range(sum, crit.height(), crit.width()), boxes);
|
||||
}
|
||||
(_, Some(Expr::Number(_) | Expr::Text(_) | Expr::Bool(_) | Expr::Error(_) | Expr::Array(_) | Expr::Missing)) => {}
|
||||
_ => self.dynamic = true,
|
||||
},
|
||||
Expr::Call(n, _)
|
||||
if matches!(n.as_str(), "INDIRECT" | "OFFSET" | "NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" | "CELL" | "INFO") =>
|
||||
{
|
||||
self.dynamic = true
|
||||
}
|
||||
Expr::Name(_) | Expr::Struct(_) => self.dynamic = true,
|
||||
Expr::Call(n, _) if gridcraft_functions::lookup(n).is_none() && !is_special(n) => self.dynamic = true,
|
||||
// A range ending in a function's result (`A1:INDEX(B1:B9,n)`), or a chain of plain
|
||||
// references the parser can't fold into one (`A1:B2:C3`), covers the cells between the
|
||||
// references inside it, not only those references. Both operands are visited once,
|
||||
// collecting their bounding boxes on the way, which `Close` then records.
|
||||
Expr::Binary(BinOp::Range, a, b) => {
|
||||
let inner = self.boxes.len();
|
||||
self.boxes.push(Vec::new());
|
||||
pending.push(PrecedentStep::Close { inner, outer: boxes });
|
||||
pending.push(PrecedentStep::Visit(b, Some(inner)));
|
||||
pending.push(PrecedentStep::Visit(a, Some(inner)));
|
||||
return;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Children are pushed in reverse so they pop left to right.
|
||||
let visit = |e: &'e Expr| PrecedentStep::Visit(e, boxes);
|
||||
match x {
|
||||
Expr::Array(rows) => pending.extend(rows.iter().flatten().rev().map(visit)),
|
||||
Expr::Unary(_, e) | Expr::Paren(e) => pending.push(visit(e)),
|
||||
Expr::Binary(_, a, b) => {
|
||||
pending.push(visit(b));
|
||||
pending.push(visit(a));
|
||||
}
|
||||
Expr::Call(_, args) => pending.extend(args.iter().rev().map(visit)),
|
||||
Expr::Invoke(c, args) => {
|
||||
pending.extend(args.iter().rev().map(visit));
|
||||
pending.push(visit(c));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_special(n: &str) -> bool {
|
||||
|
||||
@@ -2331,6 +2331,85 @@ fn median_large_values_recalculate_dependents() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #2183: `INDEX(…):INDEX(…):…` used to re-walk each operand of the left-associative range chain,
|
||||
/// costing 2^k for k operands (28 took 40 s; 60 would take millennia), on open and on every edit.
|
||||
#[test]
|
||||
fn range_operator_chain_precedents_are_linear() {
|
||||
let operands: Vec<String> = (1..=60).map(|i| format!("INDEX(A{i},1)")).collect();
|
||||
let src = format!("=SUM({})", operands.join(":"));
|
||||
let e = gridcraft_formula::parse(&src).unwrap();
|
||||
let wb = Workbook::new();
|
||||
// With the old 2^k walk this call would never return; #2183 asks for under 100 ms.
|
||||
#[allow(clippy::disallowed_methods)] // a native test: the clock is there
|
||||
let t0 = std::time::Instant::now();
|
||||
let (areas, dynamic) = crate::eval::precedents(&wb, 0, &e);
|
||||
let took = t0.elapsed();
|
||||
assert!(took < std::time::Duration::from_millis(100), "precedents took {took:?}");
|
||||
assert!(!dynamic);
|
||||
// The chain covers A1:A60, not only the 60 cells named in it.
|
||||
let whole = gridcraft_core::RangeRef::new(c("A1"), c("A60"));
|
||||
assert!(areas.iter().any(|a| a.sheet == 0 && a.range == whole), "{areas:?}");
|
||||
// Each node is visited once: 60 references plus one box per range operator.
|
||||
assert!(areas.len() < 200, "{} areas", areas.len());
|
||||
|
||||
// Through the recalculation engine: setting it finishes and editing a cell in the box recalculates.
|
||||
let mut t = T::new();
|
||||
for i in 1..=60 {
|
||||
t.set(&format!("A{i}"), "1");
|
||||
}
|
||||
t.set("B1", &src);
|
||||
assert_eq!(t.num("B1"), 60.0);
|
||||
t.set("A30", "5");
|
||||
assert_eq!(t.num("B1"), 64.0);
|
||||
}
|
||||
|
||||
/// A chained range of plain references (`A1:B2:C3`, which the parser can't fold into one
|
||||
/// reference) covers their bounding box, as the evaluator expands it: an edit inside the box but
|
||||
/// outside each operand still recalculates.
|
||||
#[test]
|
||||
fn chained_plain_range_precedents_cover_the_bounding_box() {
|
||||
let e = gridcraft_formula::parse("=SUM(A1:B2:C3)").unwrap();
|
||||
let (areas, dynamic) = crate::eval::precedents(&Workbook::new(), 0, &e);
|
||||
assert!(!dynamic);
|
||||
let whole = gridcraft_core::RangeRef::new(c("A1"), c("C3"));
|
||||
assert!(areas.iter().any(|a| a.sheet == 0 && a.range == whole), "{areas:?}");
|
||||
|
||||
let mut t = T::new();
|
||||
t.set("A1", "1");
|
||||
t.set("E1", "=SUM(A1:B2:C3)");
|
||||
assert_eq!(t.num("E1"), 1.0);
|
||||
t.set("C1", "4");
|
||||
assert_eq!(t.num("E1"), 5.0);
|
||||
}
|
||||
|
||||
/// `precedents` walks with an explicit stack: an expression nested deeper than any formula
|
||||
/// within the length limit can build still finds its references, on a small stack.
|
||||
#[test]
|
||||
fn precedents_of_deep_nesting_do_not_recurse() {
|
||||
let (areas, dynamic) = std::thread::Builder::new()
|
||||
.stack_size(256 * 1024)
|
||||
.spawn(|| {
|
||||
let mut e = gridcraft_formula::parse("=A1").unwrap();
|
||||
for _ in 0..10_000 {
|
||||
e = gridcraft_formula::Expr::Paren(Box::new(e));
|
||||
}
|
||||
let found = crate::eval::precedents(&Workbook::new(), 0, &e);
|
||||
// Dropping the deep tree recurses; leak it so only `precedents` is under test.
|
||||
std::mem::forget(e);
|
||||
found
|
||||
})
|
||||
.unwrap()
|
||||
.join()
|
||||
.unwrap();
|
||||
assert!(!dynamic);
|
||||
assert_eq!(areas.len(), 1);
|
||||
// Shallow nesting stays static.
|
||||
let e = gridcraft_formula::parse("=((((A1))))").unwrap();
|
||||
let (areas, dynamic) = crate::eval::precedents(&Workbook::new(), 0, &e);
|
||||
assert!(!dynamic);
|
||||
assert_eq!(areas.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_filename_names_the_workbook_file() {
|
||||
// Excel returns `<folder>/[<file>]<sheet>`, and the classic sheet-name formula splits it on
|
||||
|
||||
@@ -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| {
|
||||
@@ -176,8 +183,11 @@ pub(super) fn split_delimited(text: &str, delims: &[char], consecutive: bool, qu
|
||||
let mut in_q = false;
|
||||
let mut last_delim = false;
|
||||
let mut chars = text.chars().peekable();
|
||||
let mut at_start = true;
|
||||
while let Some(c) = chars.next() {
|
||||
if c == quote {
|
||||
if c == quote && (in_q || at_start) {
|
||||
at_start = false;
|
||||
last_delim = false;
|
||||
if in_q && chars.peek() == Some("e) {
|
||||
cur.push(quote);
|
||||
chars.next();
|
||||
@@ -192,9 +202,11 @@ pub(super) fn split_delimited(text: &str, delims: &[char], consecutive: bool, qu
|
||||
}
|
||||
out.push(std::mem::take(&mut cur));
|
||||
last_delim = true;
|
||||
at_start = true;
|
||||
continue;
|
||||
}
|
||||
last_delim = false;
|
||||
at_start = false;
|
||||
cur.push(c);
|
||||
}
|
||||
out.push(cur);
|
||||
|
||||
@@ -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());
|
||||
@@ -6264,6 +6378,16 @@ mod objects;
|
||||
mod python_cells;
|
||||
mod python_commands;
|
||||
mod python_files;
|
||||
|
||||
#[test]
|
||||
fn text_to_columns_keeps_a_mid_field_quote() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "'5\" pipe,abc"})).unwrap();
|
||||
s.execute("data.textToColumns", json!({"range": "A1", "delimiters": [","]})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::text("5\" pipe"));
|
||||
assert_eq!(v(&s, "B1"), Value::text("abc"));
|
||||
}
|
||||
|
||||
mod seams;
|
||||
|
||||
#[test]
|
||||
@@ -6480,6 +6604,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 +6693,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();
|
||||
|
||||
@@ -175,8 +175,8 @@ fn text_to_columns_keeps_a_py_piece_as_text() {
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "'=PY(\"x\");7"})).unwrap();
|
||||
assert_eq!(super::v(&s, "A1"), Value::text("=PY(\"x\");7"));
|
||||
s.execute("data.textToColumns", json!({"range": "A1", "delimiters": [";"]})).unwrap();
|
||||
// The default text qualifier takes the quotes away, as in Excel.
|
||||
assert_eq!(super::v(&s, "A1"), Value::text("=PY(x)"), "the piece is kept, not cleared");
|
||||
// A quote in the middle of a field is kept, as in Excel.
|
||||
assert_eq!(super::v(&s, "A1"), Value::text("=PY(\"x\")"), "the piece is kept, not cleared");
|
||||
assert_eq!(code(&s, 0, "A1"), None, "and it is not a Python cell");
|
||||
assert_eq!(super::v(&s, "B1"), Value::Number(7.0));
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -71,7 +71,8 @@ pub(super) fn accrintm(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let (issue, settle, rate) = (date(a, 0)?, date(a, 1)?, num(a, 2)?);
|
||||
let par = if has(a, 3) { num(a, 3)? } else { 1000.0 };
|
||||
let basis = basis_arg(a, 4)?;
|
||||
if issue >= settle || rate <= 0.0 || par <= 0.0 {
|
||||
// Excel accepts equal (whole-day) dates and accrues nothing; only issue after settlement is #NUM!.
|
||||
if issue > settle || rate <= 0.0 || par <= 0.0 {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
num_val(par * rate * year_frac(c.date_system(), issue, settle, basis)?)
|
||||
|
||||
@@ -317,3 +317,28 @@ fn securities_read_dates_in_the_1904_system() {
|
||||
let odd = vec![d4(2008, 11, 11), d4(2021, 3, 1), d4(2008, 10, 15), d4(2009, 3, 1), n(0.0785), n(0.0625), n(100.0), n(2.0), n(1.0)];
|
||||
close_tol(ev4("ODDFPRICE", odd), 113.597717474079, 1e-9);
|
||||
}
|
||||
|
||||
/// Excel truncates ACCRINTM's dates to whole days and accrues nothing when issue equals
|
||||
/// settlement, in every basis and both date systems; only issue after settlement is #NUM! (#4096).
|
||||
#[test]
|
||||
fn accrintm_equal_dates_accrue_zero() {
|
||||
let day = serial_from_ymd(DateSystem::D1900, 2025, 1, 15).unwrap();
|
||||
for basis in 0..=4 {
|
||||
close(ev("ACCRINTM", vec![n(day), n(day), n(0.05), n(1000.0), n(f64::from(basis))]), 0.0);
|
||||
}
|
||||
// Distinct times on the same day truncate to equal dates.
|
||||
close(ev("ACCRINTM", vec![n(day + 0.1), n(day + 0.9), n(0.05), n(1000.0), n(2.0)]), 0.0);
|
||||
close_tol(ev("ACCRINTM", vec![n(day), n(day + 1.0), n(0.05), n(1000.0), n(2.0)]), 0.1388888888888889, 1e-15);
|
||||
is_err(ev("ACCRINTM", vec![n(day + 1.0), n(day), n(0.05), n(1000.0), n(2.0)]), CellError::Num);
|
||||
// Equal dates do not excuse a zero rate or par, or an invalid basis.
|
||||
is_err(ev("ACCRINTM", vec![n(day), n(day), n(0.0), n(1000.0), n(2.0)]), CellError::Num);
|
||||
is_err(ev("ACCRINTM", vec![n(day), n(day), n(0.05), n(0.0), n(2.0)]), CellError::Num);
|
||||
is_err(ev("ACCRINTM", vec![n(day), n(day), n(0.05), n(1000.0), n(5.0)]), CellError::Num);
|
||||
|
||||
let day4 = serial_from_ymd(DateSystem::D1904, 2025, 1, 15).unwrap();
|
||||
for basis in 0..=4 {
|
||||
let mut ctx = TestCtx { sys: DateSystem::D1904, ..TestCtx::default() };
|
||||
let args = vec![n(day4), n(day4), n(0.05), n(1000.0), n(f64::from(basis))];
|
||||
close(crate::call(crate::lookup("ACCRINTM").unwrap(), &args, &mut ctx), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() };
|
||||
|
||||
@@ -520,7 +520,7 @@ fn integer_args(a: &[Arg]) -> R<Vec<u64>> {
|
||||
v.iter()
|
||||
.map(|x| {
|
||||
let t = x.trunc();
|
||||
if !(0.0..9.007_199_254_740_992e15).contains(&t) { Err(CellError::Num) } else { Ok(t as u64) }
|
||||
if !(0.0..9.007_199_254_740_992e15).contains(x) { Err(CellError::Num) } else { Ok(t as u64) }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -229,6 +229,20 @@ fn combinatorics() {
|
||||
is_err(ev("MULTINOMIAL", vec![n(-1.0), n(2.0)]), Num);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gcd_and_lcm_reject_negative_fractions_before_truncation() {
|
||||
for name in ["GCD", "LCM"] {
|
||||
for x in [-0.1, -0.5, -0.999] {
|
||||
is_err(ev2(name, x, 2.0), Num);
|
||||
is_err(ev(name, vec![rf(row(&[2.0, x]))]), Num);
|
||||
// A zero elsewhere must not hide an invalid negative argument.
|
||||
is_err(ev2(name, 0.0, x), Num);
|
||||
}
|
||||
close(ev2(name, -0.0, 2.0), if name == "GCD" { 2.0 } else { 0.0 });
|
||||
close(ev2(name, 12.9, 18.9), if name == "GCD" { 6.0 } else { 36.0 });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trig() {
|
||||
close(ev1("SIN", PI / 2.0), 1.0);
|
||||
|
||||
@@ -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,12 +71,15 @@ 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,
|
||||
macros: macro_ui::MacroUi::default(),
|
||||
started: now_ms(),
|
||||
allow_close: false,
|
||||
window_controls: false,
|
||||
viewport_commands: Vec::new(),
|
||||
bring_forward: false,
|
||||
open_switch_windows: false,
|
||||
ui_window: None,
|
||||
@@ -114,6 +117,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);
|
||||
@@ -250,6 +254,9 @@ impl SheetApp {
|
||||
macro_ui::question_box(self, &ctx);
|
||||
widgets::message_box(self, &ctx);
|
||||
widgets::toast(self, &ctx);
|
||||
// Last, so its resize cursors win over the widgets under the window edges.
|
||||
ribbon::window_frame(self, &ctx);
|
||||
self.flush_viewport_commands(&ctx);
|
||||
// Requests are admitted in `logic`, for the root viewport, so only its pass issues them.
|
||||
if ctx.viewport_id() == egui::ViewportId::ROOT {
|
||||
control::issue_screenshots(self, &ctx);
|
||||
@@ -302,6 +309,12 @@ impl SheetApp {
|
||||
}
|
||||
sel.active.a1()
|
||||
}
|
||||
|
||||
pub(crate) fn flush_viewport_commands(&mut self, ctx: &egui::Context) {
|
||||
for command in self.viewport_commands.drain(..) {
|
||||
ctx.send_viewport_cmd(command);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -148,6 +163,26 @@ impl SheetApp {
|
||||
/// UI-only commands (dialogs, view toggles that aren't saved in the file).
|
||||
fn run_ui_command(&mut self, id: &str, p: &Json) -> Option<Result<Json, String>> {
|
||||
let r = match id {
|
||||
"ui.window.minimize" => {
|
||||
self.viewport_commands.push(egui::ViewportCommand::Minimized(true));
|
||||
Ok(Json::Null)
|
||||
}
|
||||
"ui.window.maximize" => {
|
||||
let on = p.get("on").and_then(Json::as_bool).unwrap_or(true);
|
||||
self.viewport_commands.push(egui::ViewportCommand::Maximized(on));
|
||||
Ok(Json::Null)
|
||||
}
|
||||
"ui.window.close" => {
|
||||
self.viewport_commands.push(egui::ViewportCommand::Close);
|
||||
Ok(Json::Null)
|
||||
}
|
||||
"ui.window.beginResize" => {
|
||||
let Some(direction) = p.get("direction").and_then(Json::as_str).and_then(crate::parse_resize_direction) else {
|
||||
return Some(Err("direction must be north, south, east, west, northeast, northwest, southeast, or southwest".into()));
|
||||
};
|
||||
self.viewport_commands.push(egui::ViewportCommand::BeginResize(direction));
|
||||
Ok(Json::Null)
|
||||
}
|
||||
"app.checkForUpdates" | "help.checkForUpdates" => {
|
||||
self.check_for_updates();
|
||||
Ok(Json::Null)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -275,6 +275,10 @@ pub fn handle(app: &mut SheetApp, ctx: &egui::Context, method: &str, p: &Json) -
|
||||
("options.autoComplete", "Enable AutoComplete for cell values", "{on?: bool} (toggles without it) → {on}"),
|
||||
("options.highlightActiveRow", "Highlight the active cell's row", "{on?: bool} (toggles without it) → {on}"),
|
||||
("view.interfaceScale", "Interface Size", "{percent: 75–200 | \"auto\"} (100 = auto; clamped)"),
|
||||
("ui.window.minimize", "Minimize Window", ""),
|
||||
("ui.window.maximize", "Maximize Window", "{on: bool}"),
|
||||
("ui.window.close", "Close Window", ""),
|
||||
("ui.window.beginResize", "Resize Window", "{direction: north|south|east|west|northeast|northwest|southeast|southwest}"),
|
||||
("ui.dialog", "Open Dialog", ""),
|
||||
("ui.backstage", "File (Backstage view)", "{page?: home|new|open|info|saveAs|print|export|options|account|close}"),
|
||||
(
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -278,6 +278,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Save" => "Uložit",
|
||||
"Save As" => "Uložit jako",
|
||||
"Export" => "Exportovat",
|
||||
"Minimize" => "Minimalizovat",
|
||||
"Maximize" => "Maximalizovat",
|
||||
"Restore Down" => "Obnovit dolů",
|
||||
"Close" => "Zavřít",
|
||||
"Options" => "Možnosti",
|
||||
"Account" => "Účet",
|
||||
|
||||
@@ -294,6 +294,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Save" => "Speichern",
|
||||
"Save As" => "Speichern unter",
|
||||
"Export" => "Exportieren",
|
||||
"Minimize" => "Minimieren",
|
||||
"Maximize" => "Maximieren",
|
||||
"Restore Down" => "Nach unten wiederherstellen",
|
||||
"Close" => "Schließen",
|
||||
"Options" => "Optionen",
|
||||
"Account" => "Konto",
|
||||
|
||||
@@ -271,6 +271,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Save" => "Αποθήκευση",
|
||||
"Save As" => "Αποθήκευση ως",
|
||||
"Export" => "Εξαγωγή",
|
||||
"Minimize" => "Ελαχιστοποίηση",
|
||||
"Maximize" => "Μεγιστοποίηση",
|
||||
"Restore Down" => "Επαναφορά",
|
||||
"Close" => "Κλείσιμο",
|
||||
"Options" => "Επιλογές",
|
||||
"Blank workbook" => "Κενό βιβλίο εργασίας",
|
||||
|
||||
@@ -283,6 +283,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Save" => "Guardar",
|
||||
"Save As" => "Guardar como",
|
||||
"Export" => "Exportar",
|
||||
"Minimize" => "Minimizar",
|
||||
"Maximize" => "Maximizar",
|
||||
"Restore Down" => "Restaurar",
|
||||
"Close" => "Cerrar",
|
||||
"Options" => "Opciones",
|
||||
"Account" => "Cuenta",
|
||||
|
||||
@@ -370,6 +370,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Save" => "Enregistrer",
|
||||
"Save As" => "Enregistrer sous",
|
||||
"Export" => "Exporter",
|
||||
"Minimize" => "Réduire",
|
||||
"Maximize" => "Agrandir",
|
||||
"Restore Down" => "Restaurer",
|
||||
"Close" => "Fermer",
|
||||
"Options" => "Options",
|
||||
"Account" => "Compte",
|
||||
|
||||
@@ -365,6 +365,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Save" => "Salva",
|
||||
"Save As" => "Salva con nome",
|
||||
"Export" => "Esporta",
|
||||
"Minimize" => "Riduci a icona",
|
||||
"Maximize" => "Ingrandisci",
|
||||
"Restore Down" => "Ripristina",
|
||||
"Close" => "Chiudi",
|
||||
"Options" => "Opzioni",
|
||||
"Blank workbook" => "Cartella di lavoro vuota",
|
||||
|
||||
@@ -364,6 +364,9 @@ pub(super) const CORE: &[(&str, &str)] = &[
|
||||
("Save", "上書き保存"),
|
||||
("Save As", "名前を付けて保存"),
|
||||
("Export", "エクスポート"),
|
||||
("Minimize", "最小化"),
|
||||
("Maximize", "最大化"),
|
||||
("Restore Down", "元のサイズに戻す"),
|
||||
("Close", "閉じる"),
|
||||
("Options", "オプション"),
|
||||
("Blank workbook", "空白のブック"),
|
||||
|
||||
@@ -370,6 +370,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Save" => "저장",
|
||||
"Save As" => "다른 이름으로 저장",
|
||||
"Export" => "내보내기",
|
||||
"Minimize" => "최소화",
|
||||
"Maximize" => "최대화",
|
||||
"Restore Down" => "이전 크기로 복원",
|
||||
"Close" => "닫기",
|
||||
"Options" => "옵션",
|
||||
"Account" => "계정",
|
||||
|
||||
@@ -364,6 +364,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Save" => "Opslaan",
|
||||
"Save As" => "Opslaan als",
|
||||
"Export" => "Exporteren",
|
||||
"Minimize" => "Minimaliseren",
|
||||
"Maximize" => "Maximaliseren",
|
||||
"Restore Down" => "Vorig formaat",
|
||||
"Close" => "Sluiten",
|
||||
"Options" => "Opties",
|
||||
"Blank workbook" => "Lege werkmap",
|
||||
|
||||
@@ -357,6 +357,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Save" => "Zapisz",
|
||||
"Save As" => "Zapisz jako",
|
||||
"Export" => "Eksportuj",
|
||||
"Minimize" => "Minimalizuj",
|
||||
"Maximize" => "Maksymalizuj",
|
||||
"Restore Down" => "Przywróć w dół",
|
||||
"Close" => "Zamknij",
|
||||
"Options" => "Opcje",
|
||||
"Blank workbook" => "Pusty skoroszyt",
|
||||
|
||||
@@ -367,6 +367,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Save" => "Salvar",
|
||||
"Save As" => "Salvar como",
|
||||
"Export" => "Exportar",
|
||||
"Minimize" => "Minimizar",
|
||||
"Maximize" => "Maximizar",
|
||||
"Restore Down" => "Restaurar",
|
||||
"Close" => "Fechar",
|
||||
"Options" => "Opções",
|
||||
"Account" => "Conta",
|
||||
|
||||
@@ -364,6 +364,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Save" => "Salvare",
|
||||
"Save As" => "Salvare ca",
|
||||
"Export" => "Exportare",
|
||||
"Minimize" => "Minimizare",
|
||||
"Maximize" => "Maximizare",
|
||||
"Restore Down" => "Restaurare",
|
||||
"Close" => "Închidere",
|
||||
"Options" => "Opțiuni",
|
||||
"Blank workbook" => "Registru gol",
|
||||
|
||||
@@ -372,6 +372,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Save" => "Сохранить",
|
||||
"Save As" => "Сохранить как",
|
||||
"Export" => "Экспорт",
|
||||
"Minimize" => "Свернуть",
|
||||
"Maximize" => "Развернуть",
|
||||
"Restore Down" => "Восстановить",
|
||||
"Close" => "Закрыть",
|
||||
"Options" => "Параметры",
|
||||
"Account" => "Учетная запись",
|
||||
|
||||
@@ -363,6 +363,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Save" => "Sačuvaj",
|
||||
"Save As" => "Sačuvaj kao",
|
||||
"Export" => "Izvezi",
|
||||
"Minimize" => "Minimizuj",
|
||||
"Maximize" => "Maksimizuj",
|
||||
"Restore Down" => "Vrati",
|
||||
"Close" => "Zatvori",
|
||||
"Options" => "Opcije",
|
||||
"Account" => "Nalog",
|
||||
|
||||
@@ -277,6 +277,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Save" => "Kaydet",
|
||||
"Save As" => "Farklı Kaydet",
|
||||
"Export" => "Dışarı Aktar",
|
||||
"Minimize" => "Simge durumuna küçült",
|
||||
"Maximize" => "Ekranı kapla",
|
||||
"Restore Down" => "Önceki boyuta getir",
|
||||
"Close" => "Kapat",
|
||||
"Options" => "Seçenekler",
|
||||
"Blank workbook" => "Boş çalışma kitabı",
|
||||
|
||||
@@ -361,6 +361,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Save" => "Зберегти",
|
||||
"Save As" => "Зберегти як",
|
||||
"Export" => "Експорт",
|
||||
"Minimize" => "Згорнути",
|
||||
"Maximize" => "Розгорнути",
|
||||
"Restore Down" => "Відновити",
|
||||
"Close" => "Закрити",
|
||||
"Options" => "Параметри",
|
||||
"Blank workbook" => "Порожня книга",
|
||||
|
||||
@@ -367,6 +367,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Save" => "Lưu",
|
||||
"Save As" => "Lưu thành",
|
||||
"Export" => "Xuất",
|
||||
"Minimize" => "Thu nhỏ",
|
||||
"Maximize" => "Phóng to",
|
||||
"Restore Down" => "Khôi phục",
|
||||
"Close" => "Đóng",
|
||||
"Options" => "Tùy chọn",
|
||||
"Blank workbook" => "Sổ làm việc trống",
|
||||
|
||||
@@ -368,6 +368,9 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Save" => "保存",
|
||||
"Save As" => "另存为",
|
||||
"Export" => "导出",
|
||||
"Minimize" => "最小化",
|
||||
"Maximize" => "最大化",
|
||||
"Restore Down" => "向下还原",
|
||||
"Close" => "关闭",
|
||||
"Options" => "选项",
|
||||
"Account" => "账户",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -81,6 +83,20 @@ pub use services::{Chrome, Inbox, Services};
|
||||
pub use state::{NoticeKind, OpenWindow, SaveAnswer, SheetView};
|
||||
pub mod desktop_theme;
|
||||
|
||||
fn parse_resize_direction(value: &str) -> Option<egui::ResizeDirection> {
|
||||
match value {
|
||||
"north" => Some(egui::ResizeDirection::North),
|
||||
"south" => Some(egui::ResizeDirection::South),
|
||||
"east" => Some(egui::ResizeDirection::East),
|
||||
"west" => Some(egui::ResizeDirection::West),
|
||||
"northeast" => Some(egui::ResizeDirection::NorthEast),
|
||||
"northwest" => Some(egui::ResizeDirection::NorthWest),
|
||||
"southeast" => Some(egui::ResizeDirection::SouthEast),
|
||||
"southwest" => Some(egui::ResizeDirection::SouthWest),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Persisted UI preferences.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -287,6 +303,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.
|
||||
@@ -304,6 +322,10 @@ pub struct SheetApp {
|
||||
/// Every unsaved workbook has been answered for quitting (or `app.quit` asked): the next
|
||||
/// window close goes through.
|
||||
pub allow_close: bool,
|
||||
/// Linux/BSD: the window has no system frame; the title bar draws minimize, maximize and
|
||||
/// close, and the window resizes from its edges (`ribbon::window_frame`).
|
||||
pub window_controls: bool,
|
||||
viewport_commands: Vec<egui::ViewportCommand>,
|
||||
/// A quit question is waiting in the active window: [`doc_windows`] brings its native window
|
||||
/// to the front, also when the app is hidden or the window minimized (a Dock quit).
|
||||
pub bring_forward: bool,
|
||||
@@ -337,3 +359,32 @@ pub fn clean_error(e: &str) -> String {
|
||||
pub fn cell(r: u32, c: u32) -> CellRef {
|
||||
CellRef::new(r, c)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod viewport_command_tests {
|
||||
use super::parse_resize_direction;
|
||||
|
||||
#[test]
|
||||
fn resize_direction_parser_accepts_all_edges_and_rejects_unknown_values() {
|
||||
for name in ["north", "south", "east", "west", "northeast", "northwest", "southeast", "southwest"] {
|
||||
assert!(parse_resize_direction(name).is_some(), "{name}");
|
||||
}
|
||||
assert!(parse_resize_direction("middle").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_commands_queue_viewport_commands_without_a_workbook() {
|
||||
use egui::ViewportCommand;
|
||||
let mut a = crate::SheetApp::new(gridcraft_engine::Session::new(), crate::Services::default());
|
||||
for (id, params, expected) in [
|
||||
("ui.window.minimize", serde_json::json!({}), ViewportCommand::Minimized(true)),
|
||||
("ui.window.maximize", serde_json::json!({"on": true}), ViewportCommand::Maximized(true)),
|
||||
("ui.window.close", serde_json::json!({}), ViewportCommand::Close),
|
||||
("ui.window.beginResize", serde_json::json!({"direction": "east"}), ViewportCommand::BeginResize(egui::ResizeDirection::East)),
|
||||
] {
|
||||
assert!(a.run(id, params).is_ok(), "{id} should route without a workbook");
|
||||
assert!(a.viewport_commands.contains(&expected), "{id} queues {expected:?}");
|
||||
}
|
||||
assert!(a.run("ui.window.beginResize", serde_json::json!({"direction": "diagonal"})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ use insert::{draw, insert};
|
||||
use page_layout::page_layout;
|
||||
use review::review;
|
||||
pub use shortcuts::shortcut;
|
||||
pub use title_bar::{MAC_CONTENT_LEFT, MAC_TITLE_BAR, TITLE_LEFT_PAD, title_bar};
|
||||
pub use title_bar::{MAC_CONTENT_LEFT, MAC_TITLE_BAR, TITLE_LEFT_PAD, title_bar, window_frame};
|
||||
use title_bar::{TITLE_BAR_H, TITLE_CONTROL_H};
|
||||
use view::view;
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -120,6 +120,9 @@ pub fn title_bar(app: &mut SheetApp, ui: &mut Ui) {
|
||||
});
|
||||
let left_end = ui.min_rect().right();
|
||||
let right = ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if app.window_controls {
|
||||
ui.add_space(3.0 * CONTROL_W);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
let __k = icon_button(ui, Icon::Search, t.text_dim, "Search commands", vec2(28.0, ch));
|
||||
control_keytip(app, ui, &__k, "commandSearch");
|
||||
@@ -206,6 +209,120 @@ fn paint_title(ui: &Ui, center: egui::Pos2, title: &str, dirty: bool, max_w: f32
|
||||
}
|
||||
}
|
||||
|
||||
/// Width of one window-control button on the title bar.
|
||||
pub const CONTROL_W: f32 = 46.0;
|
||||
const CONTROL_TITLE_H: f32 = 38.0;
|
||||
const RESIZE_BAND: f32 = 5.0;
|
||||
|
||||
/// Draw application controls and edge resizing for frameless windows.
|
||||
pub fn window_frame(app: &mut SheetApp, ctx: &egui::Context) {
|
||||
if !app.window_controls {
|
||||
return;
|
||||
}
|
||||
let (maximized, fullscreen) = ctx.input(|i| (i.viewport().maximized.unwrap_or(false), i.viewport().fullscreen.unwrap_or(false)));
|
||||
if fullscreen {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ctx);
|
||||
let screen = ctx.content_rect();
|
||||
let controls = egui::Rect::from_min_size(egui::pos2(screen.max.x - 3.0 * CONTROL_W, screen.min.y), egui::vec2(3.0 * CONTROL_W, CONTROL_TITLE_H));
|
||||
egui::Area::new(egui::Id::unique("gc_window_controls")).order(egui::Order::Foreground).fixed_pos(controls.min).show(ctx, |ui| {
|
||||
ui.spacing_mut().item_spacing = egui::vec2(0.0, 0.0);
|
||||
ui.horizontal(|ui| {
|
||||
for kind in ["minimize", "maximize", "close"] {
|
||||
let (r, resp) = ui.allocate_exact_size(egui::vec2(CONTROL_W, CONTROL_TITLE_H), egui::Sense::click());
|
||||
let label = app.ui.language.tr(match kind {
|
||||
"minimize" => "Minimize",
|
||||
"maximize" if maximized => "Restore Down",
|
||||
"maximize" => "Maximize",
|
||||
_ => "Close",
|
||||
});
|
||||
let hot = resp.hovered() || resp.is_pointer_button_down_on();
|
||||
if hot {
|
||||
ui.painter().rect_filled(
|
||||
r,
|
||||
0.0,
|
||||
if kind == "close" {
|
||||
egui::Color32::from_rgb(0xC4, 0x2B, 0x1C)
|
||||
} else if resp.is_pointer_button_down_on() {
|
||||
t.pressed
|
||||
} else {
|
||||
t.hover
|
||||
},
|
||||
);
|
||||
}
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label));
|
||||
let m = r.center();
|
||||
let stroke = egui::Stroke::new(1.0, if kind == "close" && hot { egui::Color32::WHITE } else { t.text });
|
||||
match kind {
|
||||
"minimize" => {
|
||||
ui.painter().line_segment([egui::pos2(m.x - 5.0, m.y), egui::pos2(m.x + 5.0, m.y)], stroke);
|
||||
}
|
||||
"maximize" if maximized => {
|
||||
let front = egui::Rect::from_min_size(egui::pos2(m.x - 5.0, m.y - 3.0), egui::vec2(8.0, 8.0));
|
||||
ui.painter().rect_stroke(front, 0.0, stroke, egui::StrokeKind::Inside);
|
||||
ui.painter().line_segment([egui::pos2(m.x - 3.0, m.y - 5.0), egui::pos2(m.x + 5.0, m.y - 5.0)], stroke);
|
||||
ui.painter().line_segment([egui::pos2(m.x + 5.0, m.y - 5.0), egui::pos2(m.x + 5.0, m.y + 3.0)], stroke);
|
||||
}
|
||||
"maximize" => {
|
||||
ui.painter().rect_stroke(r.shrink(15.0), 0.0, stroke, egui::StrokeKind::Inside);
|
||||
}
|
||||
_ => {
|
||||
ui.painter().line_segment([m - egui::vec2(5.0, 5.0), m + egui::vec2(5.0, 5.0)], stroke);
|
||||
ui.painter().line_segment([m + egui::vec2(5.0, -5.0), m - egui::vec2(5.0, -5.0)], stroke);
|
||||
}
|
||||
}
|
||||
if resp.on_hover_text(label).clicked() {
|
||||
match kind {
|
||||
"minimize" => app.run_or_alert("ui.window.minimize", json!({})),
|
||||
"maximize" => app.run_or_alert("ui.window.maximize", json!({"on": !maximized})),
|
||||
"close" => app.run_or_alert("ui.window.close", json!({})),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if maximized {
|
||||
return;
|
||||
}
|
||||
let Some(pos) = ctx.input(|i| i.pointer.hover_pos()) else {
|
||||
return;
|
||||
};
|
||||
let l = pos.x <= screen.min.x + RESIZE_BAND;
|
||||
let r = pos.x >= screen.max.x - RESIZE_BAND;
|
||||
let top = pos.y <= screen.min.y + RESIZE_BAND;
|
||||
let b = pos.y >= screen.max.y - RESIZE_BAND;
|
||||
let dir = match (l, r, top, b) {
|
||||
(true, false, true, false) => Some(egui::viewport::ResizeDirection::NorthWest),
|
||||
(false, true, true, false) => Some(egui::viewport::ResizeDirection::NorthEast),
|
||||
(true, false, false, true) => Some(egui::viewport::ResizeDirection::SouthWest),
|
||||
(false, true, false, true) => Some(egui::viewport::ResizeDirection::SouthEast),
|
||||
(true, false, _, _) => Some(egui::viewport::ResizeDirection::West),
|
||||
(false, true, _, _) => Some(egui::viewport::ResizeDirection::East),
|
||||
(_, _, true, false) => Some(egui::viewport::ResizeDirection::North),
|
||||
(_, _, false, true) => Some(egui::viewport::ResizeDirection::South),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(dir) = dir
|
||||
&& ctx.input(|i| i.pointer.primary_pressed())
|
||||
&& !controls.contains(pos)
|
||||
{
|
||||
let direction = match dir {
|
||||
egui::ResizeDirection::North => "north",
|
||||
egui::ResizeDirection::South => "south",
|
||||
egui::ResizeDirection::East => "east",
|
||||
egui::ResizeDirection::West => "west",
|
||||
egui::ResizeDirection::NorthEast => "northeast",
|
||||
egui::ResizeDirection::NorthWest => "northwest",
|
||||
egui::ResizeDirection::SouthEast => "southeast",
|
||||
egui::ResizeDirection::SouthWest => "southwest",
|
||||
};
|
||||
app.run_or_alert("ui.window.beginResize", json!({"direction": direction}));
|
||||
}
|
||||
app.flush_viewport_commands(ctx);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod title_bar_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -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})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user