mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-03 08:51:30 -06:00
Compare commits
87
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19c6f70e2a | ||
|
|
85e51e009c | ||
|
|
7bb397a3c1 | ||
|
|
ef927e2e71 | ||
|
|
6558f26ba8 | ||
|
|
9172516aa8 | ||
|
|
824a6fffd2 | ||
|
|
9c6dbfdfab | ||
|
|
e9430daa9a | ||
|
|
7ddcb800ea | ||
|
|
6f52cc5090 | ||
|
|
488f9982eb | ||
|
|
c1bc0980ca | ||
|
|
5579ec9a65 | ||
|
|
7c5f417a5d | ||
|
|
3379f12a56 | ||
|
|
f988c2843e | ||
|
|
9f4ca54e9a | ||
|
|
f6a0556b9e | ||
|
|
94b292d5a8 | ||
|
|
23e09713a9 | ||
|
|
5119fb5f51 | ||
|
|
b5acb39356 | ||
|
|
9a094696fe | ||
|
|
57d7ea9bc3 | ||
|
|
e8f2becf4a | ||
|
|
ad6aca08bf | ||
|
|
b408b70986 | ||
|
|
b78afc9b3c | ||
|
|
e945d7b76d | ||
|
|
25881a8e03 | ||
|
|
aedbbba896 | ||
|
|
94f8410ccf | ||
|
|
092e5819a4 | ||
|
|
63607f881b | ||
|
|
e361f922d5 | ||
|
|
c3e3e55f83 | ||
|
|
ea2dfe66c2 | ||
|
|
50f4b4fe29 | ||
|
|
a847f839e3 | ||
|
|
3bec72f643 | ||
|
|
6ed56683bf | ||
|
|
9ac06dd8cc | ||
|
|
8b96d30c2b | ||
|
|
c1cc8264fe | ||
|
|
f46c47f3cf | ||
|
|
e2c523881f | ||
|
|
bf79931b30 | ||
|
|
52b00fe8f8 | ||
|
|
b6c4500fa7 | ||
|
|
0151f43af8 | ||
|
|
11b55e0c71 | ||
|
|
8e816dff1e | ||
|
|
745e937b0b | ||
|
|
f5030432fc | ||
|
|
71b130be2d | ||
|
|
0524c04756 | ||
|
|
6c478475e4 | ||
|
|
cd51fe110d | ||
|
|
b3a3590db6 | ||
|
|
ec7ee8d4ea | ||
|
|
84d4f6aa84 | ||
|
|
e82e91b451 | ||
|
|
113aeff07c | ||
|
|
44e9ddad45 | ||
|
|
4fbd1dc337 | ||
|
|
58aa00bae4 | ||
|
|
14ab0f8ee3 | ||
|
|
2d56f1b936 | ||
|
|
c03c61fd05 | ||
|
|
513f19ded3 | ||
|
|
2d5be2c57a | ||
|
|
e675efd307 | ||
|
|
44c3a36ea1 | ||
|
|
dd48d09605 | ||
|
|
712f45965b | ||
|
|
bf4a3969e8 | ||
|
|
f77d70502a | ||
|
|
6e7fb6ce0b | ||
|
|
d3832e98e6 | ||
|
|
1d7206ebdd | ||
|
|
fcbbfa5142 | ||
|
|
c6c54c57e6 | ||
|
|
44c663aea2 | ||
|
|
0cb1745597 | ||
|
|
b36a3a9b8b | ||
|
|
c01e46619a |
@@ -46,3 +46,5 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
| `docs/images/pivot.png` | GridCraft contributors | screenshot of GridCraft rendered offscreen (`crates/ui-egui/examples/snapshot.rs`) showing an original sample workbook | MIT OR Apache-2.0 | README screenshot; no third-party content |
|
||||
| `docs/images/format-chart.png` | GridCraft contributors | screenshot of GridCraft rendered offscreen (`crates/ui-egui/examples/snapshot.rs`) showing an original sample workbook | MIT OR Apache-2.0 | README screenshot; no third-party content |
|
||||
| `docs/images/page-break.png` | GridCraft contributors | screenshot of GridCraft rendered offscreen (`crates/ui-egui/examples/snapshot.rs`) showing an original sample workbook | MIT OR Apache-2.0 | README screenshot; no third-party content |
|
||||
| Core 14 AFM metric numbers in `crates/pdf/src/symbol.rs` (Symbol: character codes and advance widths) and `crates/pdf/src/metrics.rs` (Helvetica, Times and Courier advance widths); no font file | Adobe Systems Incorporated | Adobe's Core 14 AFM font metrics files (`Symbol.afm` 001.008 and the Helvetica, Times and Courier AFMs) | Adobe's Core 14 metrics notice: the files may be used, copied and distributed for any purpose and without charge, provided the notice is kept (quoted in `crates/pdf/src/metrics.rs`) | Numbers only, typed in from the public metrics. PDF export embeds no font: Symbol and the Times faces are base-14 fonts every PDF reader supplies, and text outside WinAnsiEncoding is set in a predefined, non-embedded Adobe-GB1 CID font |
|
||||
| Unicode values of the Symbol encoding in `crates/pdf/src/symbol.rs` (the code-to-character map; numbers only, no file copied) | Unicode, Inc. | `symbol.txt`, "Adobe Symbol Encoding to Unicode", table version 1.0 (2011-07-12), at unicode.org/Public/MAPPINGS/VENDORS/ADOBE/ | Unicode, Inc.'s notice in the file: "Unicode, Inc. hereby grants the right to freely use the information supplied in this file in the creation of products supporting the Unicode Standard, and to make copies of this file in any form for internal or external distribution as long as this notice remains attached." The file is provided as-is, without warranties | Separate from Adobe's Core 14 notice (previous row), which covers the character codes and widths; the Unicode values come from this table |
|
||||
|
||||
Generated
+243
-29
@@ -20,7 +20,7 @@ dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer",
|
||||
"atspi-common",
|
||||
"phf",
|
||||
"phf 0.14.0",
|
||||
"serde",
|
||||
"zvariant",
|
||||
]
|
||||
@@ -225,6 +225,12 @@ dependencies = [
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.8"
|
||||
@@ -468,15 +474,30 @@ version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
||||
dependencies = [
|
||||
"bit-vec 0.8.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
|
||||
dependencies = [
|
||||
"bit-vec",
|
||||
"bit-vec 0.9.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.9.1"
|
||||
@@ -1495,6 +1516,17 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fancy-regex"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298"
|
||||
dependencies = [
|
||||
"bit-set 0.8.0",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
@@ -1937,7 +1969,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -1964,7 +1996,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-calc"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
@@ -1976,7 +2008,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-chart"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"gridcraft-calc",
|
||||
"gridcraft-core",
|
||||
@@ -1987,7 +2019,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-cli"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"gridcraft-mcp",
|
||||
@@ -1999,7 +2031,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-core"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"js-sys",
|
||||
@@ -2010,13 +2042,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-engine"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"gridcraft-calc",
|
||||
"gridcraft-chart",
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
"gridcraft-functions",
|
||||
"gridcraft-math",
|
||||
"gridcraft-model",
|
||||
"gridcraft-numfmt",
|
||||
"gridcraft-pdf",
|
||||
@@ -2032,7 +2065,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-formula"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"serde",
|
||||
@@ -2041,15 +2074,25 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-functions"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-math"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"ratex-font",
|
||||
"ratex-layout",
|
||||
"ratex-parser",
|
||||
"ratex-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-mcp"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"log",
|
||||
@@ -2058,10 +2101,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-model"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
"gridcraft-math",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.1",
|
||||
@@ -2070,18 +2114,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-numfmt"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-pdf"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-ui-egui"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
@@ -2106,7 +2150,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-web"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -2123,12 +2167,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-xlsx"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
"gridcraft-functions",
|
||||
"gridcraft-math",
|
||||
"gridcraft-model",
|
||||
"quick-xml 0.41.0",
|
||||
"sha1 0.11.0",
|
||||
@@ -2967,7 +3012,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bit-set",
|
||||
"bit-set 0.10.0",
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
@@ -3543,17 +3588,37 @@ dependencies = [
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||
dependencies = [
|
||||
"phf_macros 0.11.3",
|
||||
"phf_shared 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
|
||||
dependencies = [
|
||||
"phf_macros",
|
||||
"phf_shared",
|
||||
"phf_macros 0.14.0",
|
||||
"phf_shared 0.14.0",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared 0.11.3",
|
||||
"rand 0.8.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_generator"
|
||||
version = "0.14.0"
|
||||
@@ -3561,7 +3626,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"phf_shared",
|
||||
"phf_shared 0.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_macros"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
|
||||
dependencies = [
|
||||
"phf_generator 0.11.3",
|
||||
"phf_shared 0.11.3",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3570,13 +3648,22 @@ version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"phf_generator 0.14.0",
|
||||
"phf_shared 0.14.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.14.0"
|
||||
@@ -3852,7 +3939,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand",
|
||||
"rand 0.10.3",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash 2.1.3",
|
||||
@@ -3900,6 +3987,15 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
|
||||
dependencies = [
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.3"
|
||||
@@ -3938,6 +4034,66 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08"
|
||||
|
||||
[[package]]
|
||||
name = "ratex-font"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a7b73531dabe934a3e725ed5c90d18dacf43778b7a208381390943a9a8c24db"
|
||||
dependencies = [
|
||||
"phf 0.11.3",
|
||||
"ratex-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratex-layout"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1792738e2e36683cd20de742296a5233759565efc8cd03831064a0ccfb1fe0f7"
|
||||
dependencies = [
|
||||
"ratex-font",
|
||||
"ratex-parser",
|
||||
"ratex-types",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratex-lexer"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f704add86d7bf4c4ecc7767f45ab38a44f1bbfa4b49725b2ae53c32e83b8a61e"
|
||||
dependencies = [
|
||||
"ratex-types",
|
||||
"serde",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratex-parser"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "079ec2e7ac6bc76137a6de093a173d1f901139a51318589ab9ce17139c21a6f0"
|
||||
dependencies = [
|
||||
"fancy-regex",
|
||||
"ratex-font",
|
||||
"ratex-lexer",
|
||||
"ratex-types",
|
||||
"regex",
|
||||
"regex-lite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"unicode-normalization",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ratex-types"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0cf0be3d050525f0f7d37af55a443d7648c222ccc617639950e3de1f8e6f9900"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -4037,6 +4193,12 @@ dependencies = [
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-lite"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.11"
|
||||
@@ -4314,6 +4476,17 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sctk-adwaita"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
|
||||
dependencies = [
|
||||
"log",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"tiny-skia",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -4617,7 +4790,7 @@ dependencies = [
|
||||
"const-oid",
|
||||
"der",
|
||||
"hex",
|
||||
"rand",
|
||||
"rand 0.10.3",
|
||||
"reqwest",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
@@ -4846,6 +5019,12 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "strict-num"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.28.0"
|
||||
@@ -5075,6 +5254,31 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"log",
|
||||
"tiny-skia-path",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia-path"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"bytemuck",
|
||||
"strict-num",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.4"
|
||||
@@ -5346,6 +5550,15 @@ version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-normalization"
|
||||
version = "0.1.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-properties"
|
||||
version = "0.1.4"
|
||||
@@ -5786,8 +5999,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bit-set",
|
||||
"bit-vec",
|
||||
"bit-set 0.10.0",
|
||||
"bit-vec 0.9.1",
|
||||
"bitflags 2.13.2",
|
||||
"bytemuck",
|
||||
"cfg_aliases",
|
||||
@@ -5859,7 +6072,7 @@ dependencies = [
|
||||
"android_system_properties",
|
||||
"arrayvec",
|
||||
"ash",
|
||||
"bit-set",
|
||||
"bit-set 0.10.0",
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.6.2",
|
||||
"bytemuck",
|
||||
@@ -6185,6 +6398,7 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"redox_syscall 0.4.1",
|
||||
"rustix 0.38.44",
|
||||
"sctk-adwaita",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"smol_str",
|
||||
"tracing",
|
||||
@@ -6404,7 +6618,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"serde_json",
|
||||
|
||||
+9
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -24,6 +24,7 @@ gridcraft-engine = { path = "crates/engine" }
|
||||
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 storytold/craft-libs revision.
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
@@ -47,6 +48,13 @@ 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
|
||||
# young (0.1.x), and the gate recorded in docs/decisions.md vetted this release.
|
||||
ratex-parser = "=0.1.14"
|
||||
ratex-layout = "=0.1.14"
|
||||
ratex-types = "=0.1.14"
|
||||
ratex-font = "=0.1.14"
|
||||
|
||||
# ui
|
||||
egui = "0.37"
|
||||
egui_extras = { version = "0.37", default-features = false }
|
||||
|
||||
@@ -274,3 +274,34 @@ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
|
||||
DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
Linux Wayland window decorations
|
||||
-------------------------------
|
||||
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
|
||||
commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
|
||||
for vector window controls. Its title-font features are disabled: no Cantarell
|
||||
font is embedded by this dependency and no font file is added to this repo.
|
||||
|
||||
sctk-adwaita's MIT licence:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Bartłomiej Maryńczak
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
+16
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
|
||||
|
||||
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-01 · **Change:** minor (Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis take ranges by selection on the grid; earlier: Python mode in the formula bar; earlier: Python cells stored and kept in files; earlier: equations: Insert › Equation, editor, gridcraft-math; earlier: What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear; earlier: floating objects: one selection, eight resize handles, keyboard delete; earlier: Chart data tables: live source values beneath column, bar, line, area and compatible combo charts; legend keys and borders; XLSX round-trip and print/PDF; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
|
||||
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
|
||||
@@ -22,7 +22,7 @@ This page is the summary. The detail is in:
|
||||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | Editing a cell with text that would read back as a number, boolean, date or formula (entered with a leading apostrophe) shows the apostrophe in the editor and formula bar, so Enter unchanged keeps it text (#1362) |
|
||||
| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 276 / 292 (94.5%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) |
|
||||
| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 277 / 292 (94.9%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) |
|
||||
| **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) |
|
||||
| **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) |
|
||||
| **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) |
|
||||
@@ -135,6 +135,13 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-11-02 | Dialogs take ranges by selection on the grid: reference boxes (RefEdit) with a collapse button that shrinks the dialog while the person selects, in Goal Seek, Solver (including the Model Range of Load/Save) and Data Analysis; while collapsed, the arrow keys move the selection and Shift+arrow extends it, and F4 cycles a reference's anchors |
|
||||
| 2026-11-02 | Python mode in the formula bar: Ctrl+Alt+Shift+P, `=PY` then Tab, or editing a Python cell edits its code in the formula bar, with Enter for a new line, Tab to indent and Ctrl+Enter to commit through `python.set`. The code still doesn't run |
|
||||
| 2026-11-02 | Python cells as data: `python.set` stores code in a cell, Python cells copy, move, fill and undo like formulas, and `.xlsx` reads and writes Python cells as [MS-XLSX] specifies (`_xlfn._xlws.PY(index,0 or 1)`, the code in the workbook's Python part) with cached values and spills, so such workbooks open with their results; nothing runs Python (new code shows `#BLOCKED!`), and opening a file limits code size, result size and spill ranges |
|
||||
| 2026-11-02 | Equations: Insert › Equation (a split button with a gallery of examples) and an editor with quick-insert buttons, the error underlined at its position and a live preview; `insert.equation` and `equation.edit`; LaTeX laid out by RaTeX inside the new `gridcraft-math` crate, capped and panic-guarded, and cached on the shape so that painting never runs the layout engine; xlsx writes Office Math with a plain-text fallback and the source in `descr`, and reads Office Math back into LaTeX; PDF prints equations with the standard Symbol font and `[?]` for anything else |
|
||||
| 2026-11-02 | Solver writes a Sensitivity report: Simplex LP shadow prices, reduced costs and allowable ranges from the final tableau; GRG Nonlinear reduced gradients and Lagrange multipliers from difference gradients, at a Karush-Kuhn-Tucker point only; Solver Results offers it where it applies (no score change) |
|
||||
| 2026-11-02 | Floating objects: charts, pictures, shapes and slicers share one selection with a frame of eight resize handles (each moves only its own edges, also on right-to-left sheets); Delete or Backspace deletes the selected object and Escape drops the selection; slicers follow row and column edits and sorts like the other objects; chart series follow cut-moves and sheet renames and deletes; a copied sheet gives its objects fresh ids; `ui.inspect` reports `selectedObject` and `ui.object.select` selects any object (#190, #213) |
|
||||
| 2026-11-01 | Chart data tables are available from Chart Design › Add Chart Element and Format Chart Area. Tables follow live source values and number formats, keep blank cells blank, offer legend keys and border settings, and survive XLSX save/reopen; the shared renderer includes them in print and PDF. Readiness estimates unchanged. |
|
||||
| 2026-11-01 | XLSX keeps the first visible workbook window's active sheet and display settings together, falling back to the first view when all are hidden. Tab ratios are normalized to 0–1000 on import and export; invalid numeric text uses 600. Synthetic round trips cover isolated attributes, missing views, and active tabs after sheet deletion/reordering; firstSheet remains separate #2264 work (#3057). |
|
||||
| 2026-10-31 | Esc in File › Info, Print or Options first leaves the text field; the next Esc returns to the sheet (#3035) |
|
||||
| 2026-10-31 | CSV and text open detects each column's date order: a value that reads only day first (`31/05/2026`) makes the column day first, a value that reads only month first (`12/31/2026`) makes it month first, an all-ambiguous column keeps month first with a warning, and a column with both kinds stays text with a warning (#2765) |
|
||||
@@ -250,6 +257,13 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-02 | minor | Progress log: RefEdit reference boxes in dialogs |
|
||||
| 2026-11-02 | minor | Progress log: Python mode in the formula bar |
|
||||
| 2026-11-02 | minor | Progress log: Python cells as data |
|
||||
| 2026-11-02 | minor | Progress log: equations (Insert › Equation, the editor, gridcraft-math) |
|
||||
| 2026-11-02 | minor | What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear (no score change) |
|
||||
| 2026-11-02 | minor | Progress log: one selection and eight resize handles for every floating object |
|
||||
| 2026-11-01 | minor | Chart data tables are available from Chart Design › Add Chart Element and Format Chart Area. Tables follow live source values and number formats, keep blank cells blank, offer legend keys and border settings, and survive XLSX save/reopen; the shared renderer includes them in print and PDF. Readiness estimates unchanged. |
|
||||
| 2026-11-01 | trivial | Workbook views: selected window settings stay together, tab ratios are bounded, and regressions cover isolated settings and sheet edits; readiness estimates unchanged (#3057) |
|
||||
| 2026-10-31 | minor | File-page text fields keep the view open when Esc cancels an edit (#3035) |
|
||||
| 2026-10-31 | minor | Progress log: Recently Used and Insert Function categories |
|
||||
|
||||
@@ -24,9 +24,13 @@ chrono = { workspace = true }
|
||||
# Icon + VERSIONINFO for gridcraft.exe; build.rs only uses it when the target is Windows.
|
||||
winresource = "0.1"
|
||||
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
[target.'cfg(all(unix, not(any(target_os = "macos", target_os = "linux"))))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Retain X11 fallback; draw Wayland client-side controls without bundled title fonts.
|
||||
winit = { version = "0.30", default-features = false, features = ["x11", "wayland-csd-adwaita-notitle"] }
|
||||
|
||||
[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).
|
||||
|
||||
@@ -135,6 +135,10 @@ impl Calc {
|
||||
if cell.value == Value::Error(CellError::Circ) {
|
||||
circ.push((si, c));
|
||||
}
|
||||
} else if cell.py.is_some() {
|
||||
// A Python cell lays out its kept result, so it is calculated like a formula
|
||||
// that reads nothing: its spill is blocked and unblocked like a formula's.
|
||||
self.graph.insert((si, c), Node { areas: Vec::new(), dynamic: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,11 +210,29 @@ impl Calc {
|
||||
let mut cleared: Vec<(usize, RangeRef)> = Vec::new();
|
||||
for &k in changed {
|
||||
let formula = wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.clone());
|
||||
let py = wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.py.as_ref());
|
||||
let python = py.is_some();
|
||||
// A Python cell whose result (if any) is not an array spills nothing.
|
||||
let spills = py.and_then(|p| p.result_value()).is_some_and(|v| matches!(v, Value::Array(_)));
|
||||
match formula.and_then(|f| f.expr()) {
|
||||
Some(e) => {
|
||||
let (areas, dynamic) = precedents(wb, k.0, &e);
|
||||
self.graph.insert(k, Node { areas, dynamic });
|
||||
}
|
||||
// See `rebuild`: its spill is laid out again from its result when it is calculated.
|
||||
None if python => {
|
||||
self.graph.insert(k, Node { areas: Vec::new(), dynamic: false });
|
||||
// New code has no result yet: the spill the cell had (its old result's, or a
|
||||
// formula's) goes, and what read it is recalculated, as for a formula
|
||||
// replaced by a constant.
|
||||
if !spills
|
||||
&& let Some(sh) = wb.sheet_mut(k.0)
|
||||
&& let Some(r) = sh.spill_ranges.remove(&k.1)
|
||||
{
|
||||
sh.clear_spill(r);
|
||||
cleared.push((k.0, r));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
self.graph.remove(k);
|
||||
// A formula replaced by a constant (or cleared) takes its spilled values with it.
|
||||
@@ -338,11 +360,15 @@ impl Calc {
|
||||
exprs.insert(*k, e);
|
||||
}
|
||||
}
|
||||
let python: HashMap<Key, Value> = dirty
|
||||
.iter()
|
||||
.filter_map(|k| wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.py.as_ref()).and_then(|p| p.result_value()).map(|v| (*k, v)))
|
||||
.collect();
|
||||
let now = self.now();
|
||||
let mut rng = self.rng;
|
||||
let tp = prof_now();
|
||||
let (results, spills, cycles) = {
|
||||
let mut host = PassHost::new(wb, &exprs, &mut rng, now);
|
||||
let mut host = PassHost::new(wb, &exprs, &mut rng, now).with_python(python);
|
||||
host.iterate = wb.calc.iterative;
|
||||
for k in &dirty {
|
||||
host.calculate(*k);
|
||||
@@ -547,6 +573,48 @@ pub(crate) mod tests {
|
||||
v
|
||||
}
|
||||
|
||||
/// A 1,000 x 1,000 Python result laid out from A1 in a pass, with the same values already
|
||||
/// spilled on the sheet from the last calculation, as a full recalculation finds them.
|
||||
fn big_python_spill() -> (Workbook, HashMap<Key, Value>) {
|
||||
let n = 1_000usize;
|
||||
let data: Vec<Value> = (0..n * n).map(|i| Value::Number(i as f64)).collect();
|
||||
let array = gridcraft_core::Array::new(n, n, data.clone()).unwrap();
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
let anchor = CellRef::new(0, 0);
|
||||
let py = gridcraft_model::PyCode::new("big");
|
||||
sh.set_cell(anchor, Cell { value: Value::Number(0.0), py: Some(std::sync::Arc::new(py)), ..Cell::default() });
|
||||
let area = RangeRef::new(anchor, CellRef::new(n as u32 - 1, n as u32 - 1));
|
||||
sh.spill_ranges.insert(anchor, area);
|
||||
for (i, c) in area.iter().enumerate().skip(1) {
|
||||
sh.spill.insert(c, data[i].clone());
|
||||
}
|
||||
let mut python = HashMap::default();
|
||||
python.insert((0, anchor), Value::Array(std::sync::Arc::new(array)));
|
||||
(wb, python)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_read_beside_a_large_spill_walks_none_of_it() {
|
||||
let (wb, python) = big_python_spill();
|
||||
let exprs = HashMap::default();
|
||||
let mut rng = 1u64;
|
||||
let mut host = PassHost::new(&wb, &exprs, &mut rng, 0.0).with_python(python);
|
||||
assert_eq!(host.cell_value(0, CellRef::new(0, 0)), Value::Number(0.0));
|
||||
// Beside the spill (column ALM, the 1,001st), over the rows it covers.
|
||||
let beside = RangeRef::parse("ALM1:ALM1000").unwrap();
|
||||
host.spill_visits = 0;
|
||||
assert!(host.range_values(0, beside).iter().all(Value::is_empty));
|
||||
assert!(host.visit_range(0, beside, &mut |_| true), "a disjoint read takes the fast path");
|
||||
assert_eq!(host.spill_visits, 0, "nothing of the spill is walked");
|
||||
// A read inside it still sees the spilled values, walking only the cells it reads.
|
||||
let inside = RangeRef::parse("B2:C3").unwrap();
|
||||
host.spill_visits = 0;
|
||||
assert_eq!(host.range_values(0, inside), [1_001.0, 1_002.0, 2_001.0, 2_002.0].map(Value::Number).to_vec());
|
||||
assert!(host.spill_visits <= 8, "{}", host.spill_visits);
|
||||
assert!(!host.visit_range(0, inside, &mut |_| true), "an overlapping read leaves the block to range_values");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intervals_match_a_linear_scan() {
|
||||
let mut iv: Intervals<u32> = Intervals::default();
|
||||
|
||||
@@ -45,6 +45,10 @@ pub(super) struct PassHost<'a> {
|
||||
/// Suspensions so far (tests check that a wide read at the limit needs one, not one per cell).
|
||||
#[cfg(test)]
|
||||
pub(super) suspensions: usize,
|
||||
/// Spilled values looked at by range reads (tests check that a read outside a large spill
|
||||
/// looks at none of it).
|
||||
#[cfg(test)]
|
||||
pub(super) spill_visits: usize,
|
||||
/// The formulas in `cycles`.
|
||||
cycle_seen: HashSet<Key>,
|
||||
/// Iterative calculation: a circular reference reads the cell's previous value instead of
|
||||
@@ -64,6 +68,10 @@ pub(super) struct PassHost<'a> {
|
||||
stack_top: usize,
|
||||
/// Whether an evaluation ran past the stack budget since the innermost formula started.
|
||||
out_of_stack: bool,
|
||||
/// The kept results of the Python cells in the pass: a Python cell isn't evaluated, its
|
||||
/// result is laid out (an array spills from the cell, as a formula's does). Each one is in
|
||||
/// `pending` until it is laid out, so a formula that reads it first gets its new value.
|
||||
python: HashMap<Key, Value>,
|
||||
}
|
||||
|
||||
impl<'a> PassHost<'a> {
|
||||
@@ -87,6 +95,8 @@ impl<'a> PassHost<'a> {
|
||||
also: Vec::new(),
|
||||
#[cfg(test)]
|
||||
suspensions: 0,
|
||||
#[cfg(test)]
|
||||
spill_visits: 0,
|
||||
cycle_seen: HashSet::default(),
|
||||
iterate: false,
|
||||
prev: HashMap::default(),
|
||||
@@ -96,8 +106,17 @@ impl<'a> PassHost<'a> {
|
||||
taint: false,
|
||||
stack_top: 0,
|
||||
out_of_stack: false,
|
||||
python: HashMap::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds the dirty Python cells of the pass and their kept results: like the formulas, they
|
||||
/// are pending until laid out, so every read path calculates them before reading them.
|
||||
pub(super) fn with_python(mut self, python: HashMap<Key, Value>) -> Self {
|
||||
self.pending.extend(python.keys().copied());
|
||||
self.python = python;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an array result at `k` can't spill: it is in a table, or its area runs off the sheet,
|
||||
@@ -111,7 +130,10 @@ fn spill_blocked(host: &PassHost<'_>, (sheet, anchor): Key, a: &Array) -> bool {
|
||||
let own = sh.spill_ranges.get(&anchor);
|
||||
let spilled_over = |c: CellRef| {
|
||||
!own.is_some_and(|o| o.contains(c))
|
||||
&& sh.spill_ranges.iter().any(|(o, r)| *o != anchor && r.contains(c) && !host.exprs.contains_key(&(sheet, *o)))
|
||||
&& sh.spill_ranges.iter().any(|(o, r)| {
|
||||
// A formula or Python cell recalculated in this pass lays its array out again.
|
||||
*o != anchor && r.contains(c) && !host.exprs.contains_key(&(sheet, *o)) && !host.python.contains_key(&(sheet, *o))
|
||||
})
|
||||
};
|
||||
end.row >= gridcraft_core::MAX_ROWS
|
||||
|| end.col >= gridcraft_core::MAX_COLS
|
||||
@@ -135,6 +157,42 @@ pub(super) fn spills_as_before(sheet: &gridcraft_model::Sheet, anchor: CellRef,
|
||||
.all(|(i, c)| c == anchor || (!sheet.cells.has(c) && sheet.spill.get(&c) == Some(arr.data.get(i).unwrap_or(&Value::Empty))))
|
||||
}
|
||||
|
||||
/// The area an array anchored at `anchor` covers.
|
||||
fn array_area(anchor: CellRef, a: &Array) -> RangeRef {
|
||||
let end =
|
||||
CellRef::new(anchor.row.saturating_add((a.rows as u32).saturating_sub(1)), anchor.col.saturating_add((a.cols as u32).saturating_sub(1)));
|
||||
RangeRef::new(anchor, end)
|
||||
}
|
||||
|
||||
/// The spilled values `sheet` stores inside `range`, passed to `f` until it returns false. Values
|
||||
/// outside the range are not walked: with fewer rows in the range than spilled values, each row
|
||||
/// is looked up on its own; otherwise one ordered scan visits each spilled value at most once.
|
||||
/// Returns how many values were looked at.
|
||||
fn stored_spill_in<'s>(sheet: &'s gridcraft_model::Sheet, range: RangeRef, f: &mut dyn FnMut(CellRef, &'s Value) -> bool) -> usize {
|
||||
let mut seen = 0usize;
|
||||
if sheet.spill.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
if (range.height() as usize) < sheet.spill.len() {
|
||||
for row in range.start.row..=range.end.row {
|
||||
for (c, v) in sheet.spill.range(CellRef::new(row, range.start.col)..=CellRef::new(row, range.end.col)) {
|
||||
seen += 1;
|
||||
if !f(*c, v) {
|
||||
return seen;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (c, v) in sheet.spill.range(range.start..=range.end) {
|
||||
seen += 1;
|
||||
if range.contains(*c) && !f(*c, v) {
|
||||
return seen;
|
||||
}
|
||||
}
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
impl PassHost<'_> {
|
||||
/// Calculates `k` (and the formulas it reads) with bounded stack: when [`Self::compute`] would
|
||||
/// go deeper than [`MAX_NESTED`], it suspends what is in progress, the formula it needed is
|
||||
@@ -188,6 +246,9 @@ impl PassHost<'_> {
|
||||
}
|
||||
return v;
|
||||
}
|
||||
if let Some(result) = self.python.get(&k).cloned() {
|
||||
return self.python_result(k, result);
|
||||
}
|
||||
let Some(expr) = self.exprs.get(&k) else {
|
||||
return self.wb.sheet(k.0).map(|s| s.value(k.1)).unwrap_or_default();
|
||||
};
|
||||
@@ -305,6 +366,36 @@ impl PassHost<'_> {
|
||||
v
|
||||
}
|
||||
|
||||
/// A Python cell's kept result as its value: an array spills from the cell unless it is
|
||||
/// blocked, malformed or larger than [`gridcraft_model::python::MAX_RESULT_CELLS`] (only a
|
||||
/// crafted file holds one), which shows `#SPILL!`.
|
||||
fn python_result(&mut self, k: Key, result: Value) -> Value {
|
||||
self.pending.remove(&k);
|
||||
let v = match result {
|
||||
Value::Array(a) => {
|
||||
let sane = a.rows >= 1
|
||||
&& a.cols >= 1
|
||||
&& a.rows.checked_mul(a.cols).is_some_and(|n| n == a.data.len() && n <= gridcraft_model::python::MAX_RESULT_CELLS);
|
||||
if sane {
|
||||
self.spills.insert(k, a.clone());
|
||||
a.data.first().cloned().unwrap_or_default()
|
||||
} else {
|
||||
Value::Error(CellError::Spill)
|
||||
}
|
||||
}
|
||||
v => v,
|
||||
};
|
||||
let v = match self.spills.get(&k) {
|
||||
Some(a) if spill_blocked(self, k, a) => {
|
||||
self.spills.remove(&k);
|
||||
Value::Error(CellError::Spill)
|
||||
}
|
||||
_ => v,
|
||||
};
|
||||
self.results.insert(k, v.clone());
|
||||
v
|
||||
}
|
||||
|
||||
/// A formula's previous value for iterative calculation: from the last iteration, else the
|
||||
/// value it shows. Blank, and the `#CIRC!` of a loop calculated with iteration off, are 0.
|
||||
fn previous(&self, k: Key) -> Value {
|
||||
@@ -445,26 +536,29 @@ impl Host for PassHost<'_> {
|
||||
let (h, w) = (range.height() as usize, range.width() as usize);
|
||||
let mut out = vec![Value::Empty; h * w];
|
||||
let Some(sh) = self.wb.sheet(sheet) else { return out };
|
||||
// Sparse walk: only stored cells; formula cells that are (or were) dirty go through
|
||||
// `compute`, constants are copied straight from the store.
|
||||
// Sparse walk: only stored cells; formula and Python cells that are (or were) dirty go
|
||||
// through `compute`, constants are copied straight from the store.
|
||||
let mut formulas: Vec<(usize, CellRef)> = Vec::new();
|
||||
for (c, cell) in sh.cells.iter_range(range) {
|
||||
let i = (c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize;
|
||||
if cell.formula.is_some() {
|
||||
if cell.formula.is_some() || cell.py.is_some() {
|
||||
formulas.push((i, c));
|
||||
} else if let Some(slot) = out.get_mut(i) {
|
||||
*slot = cell.value.clone();
|
||||
}
|
||||
}
|
||||
for (c, v) in sh.spill.range(range.start..=range.end) {
|
||||
if range.contains(*c) {
|
||||
let i = (c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize;
|
||||
if let Some(slot) = out.get_mut(i)
|
||||
&& slot.is_empty()
|
||||
{
|
||||
*slot = v.clone();
|
||||
}
|
||||
let _seen = stored_spill_in(sh, range, &mut |c, v| {
|
||||
let i = (c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize;
|
||||
if let Some(slot) = out.get_mut(i)
|
||||
&& slot.is_empty()
|
||||
{
|
||||
*slot = v.clone();
|
||||
}
|
||||
true
|
||||
});
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.spill_visits += _seen;
|
||||
}
|
||||
for (i, c) in formulas {
|
||||
let v = self.cell_value(sheet, c);
|
||||
@@ -473,26 +567,31 @@ impl Host for PassHost<'_> {
|
||||
}
|
||||
}
|
||||
if !self.spills.is_empty() {
|
||||
let new: Vec<(usize, Value)> = self
|
||||
.spills
|
||||
.iter()
|
||||
.filter(|(a, _)| a.0 == sheet)
|
||||
.flat_map(|(a, arr)| {
|
||||
let mut v = Vec::new();
|
||||
for r in 0..arr.rows {
|
||||
for cc in 0..arr.cols {
|
||||
let c = CellRef::new(a.1.row.saturating_add(r as u32), a.1.col.saturating_add(cc as u32));
|
||||
if c != a.1 && range.contains(c) {
|
||||
v.push((
|
||||
(c.row - range.start.row) as usize * w + (c.col - range.start.col) as usize,
|
||||
arr.get(r, cc).cloned().unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
// Only the part of each array inside the range is walked: a large array beside a small
|
||||
// read costs nothing.
|
||||
let mut new: Vec<(usize, Value)> = Vec::new();
|
||||
for (a, arr) in self.spills.iter().filter(|(a, _)| a.0 == sheet) {
|
||||
let area = array_area(a.1, arr);
|
||||
if !area.intersects(&range) {
|
||||
continue;
|
||||
}
|
||||
let (r0, r1) = (area.start.row.max(range.start.row), area.end.row.min(range.end.row));
|
||||
let (c0, c1) = (area.start.col.max(range.start.col), area.end.col.min(range.end.col));
|
||||
for row in r0..=r1 {
|
||||
for col in c0..=c1 {
|
||||
let c = CellRef::new(row, col);
|
||||
if c == a.1 {
|
||||
continue;
|
||||
}
|
||||
let v = arr.get((row - a.1.row) as usize, (col - a.1.col) as usize).cloned().unwrap_or_default();
|
||||
new.push(((row - range.start.row) as usize * w + (col - range.start.col) as usize, v));
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.spill_visits += new.len();
|
||||
}
|
||||
// Iterating: an array spilled by a formula that read a previous value.
|
||||
if self.iterate
|
||||
&& self.spills.iter().any(|(a, arr)| {
|
||||
@@ -516,21 +615,31 @@ impl Host for PassHost<'_> {
|
||||
fn visit_range(&mut self, sheet: usize, range: RangeRef, f: &mut dyn FnMut(&Value) -> bool) -> bool {
|
||||
let wb = self.wb;
|
||||
let Some(sh) = wb.sheet(sheet) else { return false };
|
||||
// Formula cells first, in row-major order like `range_values`: reading one calculates it
|
||||
// and the pass remembers the value, so the walk below gets the same value again without
|
||||
// this method holding any.
|
||||
// Formula and Python cells first, in row-major order like `range_values`: reading one
|
||||
// calculates it (a Python cell lays out its result) and the pass remembers the value, so
|
||||
// the walk below gets the same value again without this method holding any.
|
||||
for (c, cell) in sh.cells.iter_range(range) {
|
||||
if cell.formula.is_some() {
|
||||
if cell.formula.is_some() || cell.py.is_some() {
|
||||
let _ = self.cell_value(sheet, c);
|
||||
}
|
||||
}
|
||||
// Spilled values sit between the stored cells (and the formulas just calculated may have
|
||||
// produced some): leave such blocks to `range_values`.
|
||||
if self.spills.keys().any(|a| a.0 == sheet) || sh.spill.range(range.start..=range.end).any(|(c, _)| range.contains(*c)) {
|
||||
let arrays = self.spills.iter().any(|(a, arr)| a.0 == sheet && array_area(a.1, arr).intersects(&range));
|
||||
let mut stored = false;
|
||||
let _seen = stored_spill_in(sh, range, &mut |_, _| {
|
||||
stored = true;
|
||||
false
|
||||
});
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.spill_visits += _seen;
|
||||
}
|
||||
if arrays || stored {
|
||||
return false;
|
||||
}
|
||||
for (c, cell) in sh.cells.iter_range(range) {
|
||||
let go = if cell.formula.is_some() { f(&self.cell_value(sheet, c)) } else { f(&cell.value) };
|
||||
let go = if cell.formula.is_some() || cell.py.is_some() { f(&self.cell_value(sheet, c)) } else { f(&cell.value) };
|
||||
if !go {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -2312,3 +2312,63 @@ fn loan_schedule_residue_keeps_excels_sign() {
|
||||
}
|
||||
assert_eq!(t.get("P7"), Value::Text("".into()));
|
||||
}
|
||||
|
||||
/// Makes `at` a Python cell showing `cached` (its value before this calculation), with `result`
|
||||
/// as its kept result, and returns the cell's key for `Calc::cells_changed`.
|
||||
fn plant_python(t: &mut T, at: &str, cached: Value, result: Value) -> (usize, CellRef) {
|
||||
use gridcraft_model::python::{PyCode, PyOutput};
|
||||
let output = PyOutput { value: result, type_name: "list".into(), preview: String::new(), convertible: true };
|
||||
let py = PyCode { output: Some(output), ..PyCode::new("[5, 6]") };
|
||||
let cell = Cell { value: cached, formula: None, style: gridcraft_model::StyleId::default(), py: Some(std::sync::Arc::new(py)) };
|
||||
t.wb.sheet_mut(0).unwrap().cells.set(c(at), cell);
|
||||
(0, c(at))
|
||||
}
|
||||
|
||||
fn column(values: &[f64]) -> Value {
|
||||
let data = values.iter().map(|v| Value::Number(*v)).collect();
|
||||
Value::Array(std::sync::Arc::new(gridcraft_core::Array::new(values.len(), 1, data).unwrap()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formulas_read_a_dirty_python_cell_after_it_is_laid_out() {
|
||||
// B1's result can't spill (B2 holds a value), so it is #SPILL!. A1, A2 and A3 come before
|
||||
// it in the pass; each must lay it out first rather than read its old value, 1.
|
||||
let build = |t: &mut T| {
|
||||
t.set("B2", "10");
|
||||
t.set("B3", "100");
|
||||
t.set("A1", "=B1");
|
||||
t.set("A2", "=MAX(B1:B3)");
|
||||
t.set("A3", "=SUM(B1:B3)");
|
||||
t.set("A4", "=COUNTA(B1:B3)");
|
||||
t.set("A5", "=SUMPRODUCT(--ISERROR(B1:B3))");
|
||||
};
|
||||
let check = |t: &T, how: &str| {
|
||||
assert_eq!(t.get("B1"), Value::Error(CellError::Spill), "{how}");
|
||||
assert_eq!(t.get("A1"), Value::Error(CellError::Spill), "{how}: a reference");
|
||||
assert_eq!(t.get("A2"), Value::Error(CellError::Spill), "{how}: a range read");
|
||||
assert_eq!(t.get("A3"), Value::Error(CellError::Spill), "{how}: a range walked cell by cell");
|
||||
assert_eq!(t.num("A4"), 3.0, "{how}");
|
||||
assert_eq!(t.num("A5"), 1.0, "{how}: the error is seen in the range");
|
||||
};
|
||||
|
||||
// A full recalculation.
|
||||
let mut t = T::new();
|
||||
build(&mut t);
|
||||
plant_python(&mut t, "B1", Value::Number(1.0), column(&[5.0, 6.0]));
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
check(&t, "full");
|
||||
|
||||
// An incremental one: B1 becomes a Python cell after its readers.
|
||||
let mut t = T::new();
|
||||
t.set("B1", "1");
|
||||
build(&mut t);
|
||||
assert_eq!((t.num("A1"), t.num("A3")), (1.0, 111.0));
|
||||
let k = plant_python(&mut t, "B1", Value::Number(1.0), column(&[5.0, 6.0]));
|
||||
t.calc.cells_changed(&mut t.wb, &[k]);
|
||||
check(&t, "incremental");
|
||||
|
||||
// Unblocked, the readers see the new array.
|
||||
t.set("B2", "");
|
||||
assert_eq!((t.num("B1"), t.num("B2")), (5.0, 6.0));
|
||||
assert_eq!((t.num("A1"), t.num("A2"), t.num("A3")), (5.0, 100.0, 111.0));
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ mod frame;
|
||||
mod kinds;
|
||||
mod legend;
|
||||
mod lines;
|
||||
mod table;
|
||||
mod trend;
|
||||
|
||||
use cartesian::{cartesian, scatter};
|
||||
|
||||
@@ -208,7 +208,9 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
|
||||
// Outer levels padded to the slots (forecast slots stay in the last group).
|
||||
let outer: Vec<Vec<String>> =
|
||||
data.category_levels.iter().map(|l| (0..ncat_slots).map(|j| l.get(j).cloned().unwrap_or_default()).collect()).collect();
|
||||
let plot = frame(ctx, area, CatAxis::Labels(&labels, &outer), &y1, y2.as_ref(), horizontal, chart.gridlines);
|
||||
let (area, table) = super::table::reserve(ctx, chart, data, area, ncat_slots);
|
||||
let category_axis = super::table::category_axis(&table, &labels, &outer);
|
||||
let plot = frame(ctx, area, category_axis, &y1, y2.as_ref(), horizontal, chart.gridlines);
|
||||
if !plot.ok() {
|
||||
return;
|
||||
}
|
||||
@@ -355,6 +357,7 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
|
||||
draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels, (0.5, ncat_slots as f64 + 0.5));
|
||||
}
|
||||
draw_trend_labels(ctx, plot, &trend_labels);
|
||||
super::table::draw(ctx, chart, data, plot, table.as_ref());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- scatter & bubble
|
||||
|
||||
@@ -74,12 +74,14 @@ pub(super) enum CatAxis<'a> {
|
||||
/// Category labels and, for a multi-level axis, the outer levels (nearest the labels first;
|
||||
/// one entry per category, non-empty where a group starts).
|
||||
Labels(&'a [String], &'a [Vec<String>]),
|
||||
/// A data table supplies the labels below a vertical chart. Its stub must clear the plot.
|
||||
Table(&'a [String], f32),
|
||||
Numeric(Scale, &'a Fmt),
|
||||
}
|
||||
|
||||
/// Groups of an outer category level: `(first, end)` index pairs, a group starting at every
|
||||
/// non-empty entry (and at the first category).
|
||||
fn level_groups(level: &[String], n: usize) -> Vec<(usize, usize)> {
|
||||
pub(super) fn level_groups(level: &[String], n: usize) -> Vec<(usize, usize)> {
|
||||
let mut starts: Vec<usize> = (0..n).filter(|&j| j == 0 || level.get(j).is_some_and(|t| !t.is_empty())).collect();
|
||||
starts.push(n);
|
||||
starts.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))).collect()
|
||||
@@ -101,9 +103,13 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
|
||||
let ls = LABEL_SIZE;
|
||||
let lh = ls * 1.4;
|
||||
let ylabels = y.labels();
|
||||
let stub = match &cat {
|
||||
CatAxis::Table(_, width) => *width,
|
||||
_ => 0.0,
|
||||
};
|
||||
let maxw = |ctx: &Ctx, l: &[(f64, String)]| l.iter().map(|(_, t)| ctx.tw(t, ls)).fold(0.0f32, f32::max);
|
||||
if !horizontal {
|
||||
let left = (maxw(ctx, &ylabels) + 6.0).min(area.w * 0.4);
|
||||
let left = (maxw(ctx, &ylabels) + 6.0).min(area.w * 0.4).max(stub);
|
||||
let y2labels = y2.map(ValAxis::labels);
|
||||
let right = match (&y2labels, &cat) {
|
||||
(Some(l), _) => (maxw(ctx, l) + 6.0).min(area.w * 0.3),
|
||||
@@ -112,7 +118,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
|
||||
};
|
||||
let levels = match &cat {
|
||||
CatAxis::Labels(_, o) => o.len() as f32,
|
||||
CatAxis::Numeric(..) => 0.0,
|
||||
CatAxis::Numeric(..) | CatAxis::Table(..) => 0.0,
|
||||
};
|
||||
let plot = Bx { x: area.x + left, y: area.y + lh * 0.5, w: area.w - left - right, h: area.h - lh * 1.5 - 4.0 - lh * levels };
|
||||
if !plot.ok() {
|
||||
@@ -135,6 +141,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
|
||||
let y0 = plot.bottom() - y.scale.frac(zero) * plot.h;
|
||||
ctx.hline(plot.x, plot.right(), y0, AXIS, 0.75);
|
||||
match cat {
|
||||
CatAxis::Table(..) => {}
|
||||
CatAxis::Labels(l, outer) => {
|
||||
let n = l.len().max(1);
|
||||
let cw = plot.w / n as f32;
|
||||
@@ -181,6 +188,7 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
|
||||
} else {
|
||||
let (labels, outer): (Vec<String>, &[Vec<String>]) = match cat {
|
||||
CatAxis::Labels(l, o) => (l.to_vec(), o),
|
||||
CatAxis::Table(l, _) => (l.to_vec(), &[]),
|
||||
CatAxis::Numeric(..) => (vec![], &[]),
|
||||
};
|
||||
let lw = (labels.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.35);
|
||||
@@ -189,7 +197,8 @@ pub(super) fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Opti
|
||||
outer.iter().map(|l| (l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max) + 6.0).min(area.w * 0.15)).collect();
|
||||
let levels_w: f32 = level_w.iter().sum();
|
||||
let right = ylabels.last().map(|(_, t)| ctx.tw(t, ls) / 2.0).unwrap_or(0.0).min(area.w * 0.2) + 2.0;
|
||||
let plot = Bx { x: area.x + lw + levels_w, y: area.y + 2.0, w: area.w - lw - levels_w - right, h: area.h - lh - 6.0 };
|
||||
let left = (lw + levels_w).max(stub);
|
||||
let plot = Bx { x: area.x + left, y: area.y + 2.0, w: area.w - left - right, h: area.h - lh - 6.0 };
|
||||
if !plot.ok() {
|
||||
return plot;
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ fn swatch_w(sw: Swatch, s: f32) -> f32 {
|
||||
if sw == Swatch::Box { s } else { s * 2.0 }
|
||||
}
|
||||
|
||||
fn draw_swatch(ctx: &mut Ctx, sw: Swatch, x: f32, yc: f32, s: f32, color: Rgba) {
|
||||
pub(super) fn draw_swatch(ctx: &mut Ctx, sw: Swatch, x: f32, yc: f32, s: f32, color: Rgba) {
|
||||
match sw {
|
||||
Swatch::Box => ctx.rect(x, yc - s / 2.0, s, s, color, None),
|
||||
Swatch::Line => ctx.hline(x, x + 2.0 * s, yc, color, 2.0),
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! The chart data table uses the same primitives as the plot, including in print/PDF output.
|
||||
|
||||
use super::frame::{Bx, CatAxis, level_groups};
|
||||
use super::legend::{Swatch, cat_label, draw_swatch, plot_kind};
|
||||
use super::{AXIS, Chart, ChartData, ChartKind, Ctx, Fmt, HAlign, LABEL_SIZE, TEXT, VAlign};
|
||||
|
||||
pub(super) struct Layout {
|
||||
top: f32,
|
||||
row_height: f32,
|
||||
headers: usize,
|
||||
stub: f32,
|
||||
columns: usize,
|
||||
}
|
||||
|
||||
pub(super) fn category_axis<'a>(layout: &Option<Layout>, labels: &'a [String], outer: &'a [Vec<String>]) -> CatAxis<'a> {
|
||||
match layout {
|
||||
Some(t) => CatAxis::Table(labels, t.stub),
|
||||
None => CatAxis::Labels(labels, outer),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reserve at most half the available height. A table that cannot fit is explicitly labelled
|
||||
/// instead of dropping rows or allocating a series × point-sized matrix.
|
||||
pub(super) fn reserve(ctx: &mut Ctx, chart: &Chart, data: &ChartData, mut area: Bx, columns: usize) -> (Bx, Option<Layout>) {
|
||||
if chart.data_table.is_none() || !chart.supports_data_table() {
|
||||
return (area, None);
|
||||
}
|
||||
let headers = data.category_levels.len() + 1;
|
||||
let rows = headers + data.series.len();
|
||||
let row_height = LABEL_SIZE * 1.4 + 4.0;
|
||||
let height = row_height * rows as f32;
|
||||
let key_width = if chart.data_table.as_ref().is_some_and(|t| t.show_keys) { 22.0 } else { 0.0 };
|
||||
let names = data.series.iter().take(32).map(|s| ctx.tw(&s.name, LABEL_SIZE)).fold(0.0f32, f32::max);
|
||||
let stub = (names + key_width + 10.0).clamp(24.0, (area.w * 0.3).max(24.0));
|
||||
if columns == 0 || columns > 128 || data.series.len() > 32 || height > area.h * 0.5 || (area.w * 0.6) / (columns as f32) < 18.0 {
|
||||
let message = ctx.fit("Data table needs more space", LABEL_SIZE, area.w);
|
||||
ctx.text(area.cx(), area.bottom(), &message, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Bottom));
|
||||
area.h -= row_height;
|
||||
return (area, None);
|
||||
}
|
||||
area.h -= height;
|
||||
(area, Some(Layout { top: area.bottom(), row_height, headers, stub, columns }))
|
||||
}
|
||||
|
||||
pub(super) fn draw(ctx: &mut Ctx, chart: &Chart, data: &ChartData, plot: Bx, layout: Option<&Layout>) {
|
||||
let Some(layout) = layout else { return };
|
||||
let Some(settings) = &chart.data_table else { return };
|
||||
let x0 = plot.x - layout.stub;
|
||||
let top = layout.top;
|
||||
let rows = layout.headers + data.series.len();
|
||||
let bottom = top + layout.row_height * rows as f32;
|
||||
let cw = plot.w / layout.columns as f32;
|
||||
// Outermost category level first, then the individual categories.
|
||||
for (i, level) in data.category_levels.iter().rev().enumerate() {
|
||||
for (first, end) in level_groups(level, layout.columns) {
|
||||
let text = level.get(first).map(String::as_str).unwrap_or("");
|
||||
let text = ctx.fit(text, LABEL_SIZE, (end - first) as f32 * cw - 4.0);
|
||||
let x = plot.x + (first + end) as f32 * cw / 2.0;
|
||||
ctx.text(x, top + (i as f32 + 0.5) * layout.row_height, &text, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Middle));
|
||||
}
|
||||
}
|
||||
let header_y = top + (layout.headers as f32 - 0.5) * layout.row_height;
|
||||
let n = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0));
|
||||
for j in 0..layout.columns {
|
||||
let text = if j < n { cat_label(data, j) } else { String::new() };
|
||||
let text = ctx.fit(&text, LABEL_SIZE, cw - 4.0);
|
||||
ctx.text(plot.x + (j as f32 + 0.5) * cw, header_y, &text, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Middle));
|
||||
}
|
||||
for (i, series) in data.series.iter().enumerate() {
|
||||
let y = top + (layout.headers as f32 + i as f32 + 0.5) * layout.row_height;
|
||||
let name_x = if settings.show_keys { x0 + 25.0 } else { x0 + 4.0 };
|
||||
if settings.show_keys {
|
||||
let swatch = match plot_kind(chart, series) {
|
||||
ChartKind::Line | ChartKind::LineStacked => Swatch::Line,
|
||||
ChartKind::LineMarkers => Swatch::LineMarker,
|
||||
_ => Swatch::Box,
|
||||
};
|
||||
draw_swatch(ctx, swatch, x0 + 4.0, y, 7.0, series.color);
|
||||
}
|
||||
let name = ctx.fit(&series.name, LABEL_SIZE, plot.x - name_x - 4.0);
|
||||
ctx.text(name_x, y, &name, LABEL_SIZE, TEXT, (HAlign::Left, VAlign::Middle));
|
||||
let fmt = Fmt::new(&series.number_format);
|
||||
for j in 0..layout.columns {
|
||||
let value = series.values.get(j).copied().flatten().map(|v| fmt.format(v, None)).unwrap_or_default();
|
||||
let text = ctx.fit(&value, LABEL_SIZE, cw - 4.0);
|
||||
ctx.text(plot.x + (j as f32 + 0.5) * cw, y, &text, LABEL_SIZE, TEXT, (HAlign::Center, VAlign::Middle));
|
||||
}
|
||||
}
|
||||
if settings.horizontal_border {
|
||||
for row in 1..rows {
|
||||
ctx.hline(x0, plot.right(), top + row as f32 * layout.row_height, AXIS, 0.75);
|
||||
}
|
||||
}
|
||||
if settings.vertical_border {
|
||||
ctx.vline(plot.x, top, bottom, AXIS, 0.75);
|
||||
// The outer headers span groups; category dividers begin at the innermost header.
|
||||
for j in 1..layout.columns {
|
||||
ctx.vline(plot.x + j as f32 * cw, top + (layout.headers - 1) as f32 * layout.row_height, bottom, AXIS, 0.75);
|
||||
}
|
||||
}
|
||||
if settings.outline {
|
||||
ctx.hline(x0, plot.right(), top, AXIS, 0.75);
|
||||
ctx.hline(x0, plot.right(), bottom, AXIS, 0.75);
|
||||
ctx.vline(x0, top, bottom, AXIS, 0.75);
|
||||
ctx.vline(plot.right(), top, bottom, AXIS, 0.75);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ use gridcraft_model::{Anchor, Chart, ChartKind, Color, LegendPos, Series, Sparkl
|
||||
|
||||
use crate::*;
|
||||
|
||||
mod data_table;
|
||||
|
||||
const ALL_KINDS: [ChartKind; 26] = [
|
||||
ChartKind::ColumnClustered,
|
||||
ChartKind::ColumnStacked,
|
||||
@@ -43,6 +45,7 @@ fn chart(kind: ChartKind) -> Chart {
|
||||
series: vec![],
|
||||
legend: LegendPos::Bottom,
|
||||
data_labels: true,
|
||||
data_table: None,
|
||||
gridlines: true,
|
||||
style: 0,
|
||||
x_title: Some("Quarter".into()),
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
use super::*;
|
||||
use gridcraft_model::ChartDataTable;
|
||||
|
||||
#[test]
|
||||
fn tables_draw_source_values_for_every_supported_chart_kind() {
|
||||
for kind in ALL_KINDS {
|
||||
let mut c = chart(kind);
|
||||
c.legend = LegendPos::None;
|
||||
c.data_labels = false;
|
||||
c.data_table = Some(ChartDataTable::default());
|
||||
let supported = c.supports_data_table();
|
||||
let d = sample(kind);
|
||||
let prims = render(&c, &d, 900.0, 500.0, &ApproxMeasure);
|
||||
assert_sane(&prims, 900.0, 500.0);
|
||||
let labels = texts(&prims);
|
||||
// Source amounts, including on 100% stacked charts, not shares or running totals.
|
||||
if supported {
|
||||
for value in ["$12,000", "-$4,000", "$38,000", "$61,870"] {
|
||||
assert!(labels.contains(&value), "{kind:?}: missing {value}");
|
||||
}
|
||||
} else {
|
||||
c.data_table = None;
|
||||
assert_eq!(prims, render(&c, &d, 900.0, 500.0, &ApproxMeasure));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_columns_align_with_plot_and_keep_missing_values_blank() {
|
||||
let mut c = chart(ChartKind::ColumnClustered);
|
||||
c.title = Some(String::new());
|
||||
c.x_title = None;
|
||||
c.y_title = None;
|
||||
c.legend = LegendPos::None;
|
||||
c.data_labels = false;
|
||||
c.data_table = Some(ChartDataTable::default());
|
||||
let color = [1, 2, 3, 255];
|
||||
let mut s = series("Rate", &[0.25, 0.0, 0.75], color, c.kind);
|
||||
s.values[1] = None;
|
||||
s.number_format = "0.0%".into();
|
||||
let d = ChartData { categories: ["Jan", "Feb", "Mar"].map(String::from).to_vec(), series: vec![s], ..Default::default() };
|
||||
let p = render(&c, &d, 600.0, 400.0, &ApproxMeasure);
|
||||
let at = |text: &str| -> (f32, f32) {
|
||||
p.iter()
|
||||
.find_map(|p| match p {
|
||||
Prim::Text { text: t, x, y, .. } if t == text => Some((*x, *y)),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
let (x1, y1) = at("25.0%");
|
||||
let (x3, y3) = at("75.0%");
|
||||
let header_y = at("Jan").1;
|
||||
assert!(y1 > header_y && (y1 - y3).abs() < 0.01);
|
||||
assert_eq!(at("Jan").0, x1);
|
||||
assert_eq!(at("Mar").0, x3);
|
||||
assert_eq!(texts(&p).iter().filter(|t| **t == "Jan").count(), 1, "the table replaces category-axis labels");
|
||||
let centers: Vec<f32> = p
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
Prim::Rect { x, y, w, fill: Some(f), .. } if *f == color && *y < header_y => Some(x + w / 2.0),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(centers.len(), 2);
|
||||
assert!((centers[0] - x1).abs() < 0.01 && (centers[1] - x3).abs() < 0.01);
|
||||
let cells: Vec<&str> = p
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
Prim::Text { y, text, .. } if (*y - y1).abs() < 0.01 => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(cells, ["Rate", "25.0%", "75.0%"], "blank cells are not zeroes");
|
||||
assert!(
|
||||
p.iter().any(|p| matches!(p, Prim::Rect { y, fill: Some(f), .. } if *f == color && *y > header_y)),
|
||||
"legend key independent of hidden chart legend"
|
||||
);
|
||||
c.data_table = Some(ChartDataTable { show_keys: false, ..Default::default() });
|
||||
let off = render(&c, &d, 600.0, 400.0, &ApproxMeasure);
|
||||
assert!(!off.iter().any(|p| matches!(p, Prim::Rect { y, fill: Some(f), .. } if *f == color && *y > header_y)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_borders_are_independent_and_multilevel_headers_are_kept() {
|
||||
let mut c = chart(ChartKind::Line);
|
||||
c.legend = LegendPos::None;
|
||||
c.data_labels = false;
|
||||
let mut d = sample(c.kind);
|
||||
d.category_levels = vec![vec!["2025".into(), "".into(), "".into(), "2026".into(), "".into(), "".into()]];
|
||||
for (h, v, outline, want) in [(false, false, false, 0), (true, false, false, 4), (false, true, false, 6), (false, false, true, 4)] {
|
||||
c.data_table = Some(ChartDataTable { horizontal_border: h, vertical_border: v, outline, show_keys: false });
|
||||
let p = render(&c, &d, 900.0, 600.0, &ApproxMeasure);
|
||||
let year_y = p
|
||||
.iter()
|
||||
.find_map(|p| match p {
|
||||
Prim::Text { text, y, .. } if text == "2025" => Some(*y),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(texts(&p).contains(&"2026"));
|
||||
let lines = p.iter().filter(|p| matches!(p, Prim::Line { pts, .. } if pts.iter().all(|p| p[1] > year_y - 9.0))).count();
|
||||
assert_eq!(lines, want, "{h} {v} {outline}");
|
||||
assert_sane(&p, 900.0, 600.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dense_and_tiny_tables_are_bounded_and_explicit() {
|
||||
let mut c = chart(ChartKind::ColumnClustered);
|
||||
c.data_table = Some(ChartDataTable::default());
|
||||
let mut d = sample(c.kind);
|
||||
d.categories = (0..20_000).map(|i| i.to_string()).collect();
|
||||
let p = render(&c, &d, 600.0, 400.0, &ApproxMeasure);
|
||||
assert!(texts(&p).contains(&"Data table needs more space"));
|
||||
assert_sane(&p, 600.0, 400.0);
|
||||
d.categories.clear();
|
||||
for (w, h) in [(1.0, 1.0), (10.0, 10.0), (40.0, 35.0), (200.0, 100.0)] {
|
||||
assert_sane(&render(&c, &d, w, h, &ApproxMeasure), w, h);
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,18 @@ pub enum CellError {
|
||||
Calc,
|
||||
/// Circular reference that could not be resolved (shown as 0 by Excel; we keep it explicit).
|
||||
Circ,
|
||||
/// A Python cell's code raised an error, or Python stopped.
|
||||
Python,
|
||||
/// A Python cell is waiting to run.
|
||||
Busy,
|
||||
/// A Python cell ran longer than the time limit.
|
||||
Timeout,
|
||||
/// Python can't run here: the workbook's content isn't enabled, or this build has no Python.
|
||||
Blocked,
|
||||
}
|
||||
|
||||
impl CellError {
|
||||
pub const ALL: [CellError; 10] = [
|
||||
pub const ALL: [CellError; 14] = [
|
||||
CellError::Null,
|
||||
CellError::Div0,
|
||||
CellError::Value,
|
||||
@@ -35,6 +43,10 @@ impl CellError {
|
||||
CellError::GettingData,
|
||||
CellError::Spill,
|
||||
CellError::Calc,
|
||||
CellError::Python,
|
||||
CellError::Busy,
|
||||
CellError::Timeout,
|
||||
CellError::Blocked,
|
||||
];
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
@@ -49,9 +61,16 @@ impl CellError {
|
||||
CellError::Spill => "#SPILL!",
|
||||
CellError::Calc => "#CALC!",
|
||||
CellError::Circ => "#CIRC!",
|
||||
CellError::Python => "#PYTHON!",
|
||||
CellError::Busy => "#BUSY!",
|
||||
CellError::Timeout => "#TIMEOUT!",
|
||||
CellError::Blocked => "#BLOCKED!",
|
||||
}
|
||||
}
|
||||
/// `ERROR.TYPE` number.
|
||||
/// `ERROR.TYPE` number. Microsoft documents 1 to 8; 9 (#SPILL!), 11 (#BLOCKED!) and 14 (#CALC!)
|
||||
/// are the numbers Excel returns. Microsoft documents no number for #PYTHON!, #BUSY! or
|
||||
/// #TIMEOUT!: 19, 20 and 21 are GridCraft's own choice, after the numbers Excel is known to use,
|
||||
/// and may change if Excel's numbers become known.
|
||||
pub fn code(&self) -> u32 {
|
||||
match self {
|
||||
CellError::Null => 1,
|
||||
@@ -65,6 +84,10 @@ impl CellError {
|
||||
CellError::Spill => 9,
|
||||
CellError::Calc => 14,
|
||||
CellError::Circ => 15,
|
||||
CellError::Blocked => 11,
|
||||
CellError::Python => 19,
|
||||
CellError::Busy => 20,
|
||||
CellError::Timeout => 21,
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<CellError> {
|
||||
|
||||
@@ -23,6 +23,7 @@ log = { workspace = true }
|
||||
gridcraft-xlsx = { workspace = true }
|
||||
gridcraft-chart = { workspace = true }
|
||||
gridcraft-pdf = { workspace = true }
|
||||
gridcraft-math = { workspace = true }
|
||||
image = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod analysis;
|
||||
pub mod data;
|
||||
pub mod draw;
|
||||
pub mod edit;
|
||||
pub mod equation;
|
||||
pub mod extra;
|
||||
pub mod file;
|
||||
pub mod forecast;
|
||||
@@ -15,6 +16,7 @@ pub mod inspect;
|
||||
pub mod macros;
|
||||
pub mod pivot;
|
||||
pub mod print;
|
||||
pub mod python;
|
||||
pub mod review;
|
||||
pub mod sheet;
|
||||
pub mod slicer;
|
||||
@@ -40,7 +42,11 @@ pub struct CommandSpec {
|
||||
pub label: &'static str,
|
||||
/// Ribbon/menu placement, e.g. `["Home", "Font"]`. Empty = not in menus.
|
||||
pub menu: &'static [&'static str],
|
||||
/// Portable notation: `Cmd` is ⌘ on macOS and Ctrl elsewhere.
|
||||
pub shortcut: Option<&'static str>,
|
||||
/// macOS override when its key differs. Equal to `shortcut` marks a Mac-only shortcut
|
||||
/// (Align Left/Center): other platforms show none. See [`CommandInfo::shortcut_for_platform`].
|
||||
pub shortcut_mac: Option<&'static str>,
|
||||
pub params: &'static str,
|
||||
pub enabled: Enabled,
|
||||
pub run: Run,
|
||||
@@ -54,12 +60,32 @@ pub struct CommandInfo {
|
||||
pub label: &'static str,
|
||||
pub menu: Vec<&'static str>,
|
||||
pub shortcut: Option<&'static str>,
|
||||
pub shortcut_mac: Option<&'static str>,
|
||||
pub params: &'static str,
|
||||
pub enabled: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disabled_reason: Option<String>,
|
||||
}
|
||||
|
||||
impl CommandInfo {
|
||||
/// Format the portable shortcut notation for the runtime operating system.
|
||||
pub fn shortcut_for_platform(&self, mac: bool) -> Option<String> {
|
||||
let keys = if mac {
|
||||
self.shortcut_mac.or(self.shortcut)
|
||||
} else if self.shortcut_mac.is_some() && self.shortcut_mac == self.shortcut {
|
||||
// An identical Mac override marks a Mac-only display shortcut.
|
||||
// The portable field remains unchanged for legacy CLI consumers.
|
||||
None
|
||||
} else {
|
||||
self.shortcut
|
||||
}?;
|
||||
if mac {
|
||||
return Some(keys.to_owned());
|
||||
}
|
||||
Some(keys.split('+').map(|part| if part == "Cmd" { "Ctrl" } else { part }).collect::<Vec<_>>().join("+"))
|
||||
}
|
||||
}
|
||||
|
||||
impl CommandSpec {
|
||||
pub fn info(&self, s: &Session) -> CommandInfo {
|
||||
let e = (self.enabled)(s);
|
||||
@@ -68,6 +94,7 @@ impl CommandSpec {
|
||||
label: self.label,
|
||||
menu: self.menu.to_vec(),
|
||||
shortcut: self.shortcut,
|
||||
shortcut_mac: self.shortcut_mac,
|
||||
params: self.params,
|
||||
enabled: e.is_ok(),
|
||||
disabled_reason: e.err(),
|
||||
@@ -118,13 +145,13 @@ pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> {
|
||||
|
||||
macro_rules! cmd {
|
||||
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: true }
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, shortcut_mac: None, params: $params, enabled: $en, run: $run, journal: true, undoable: true }
|
||||
};
|
||||
(query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false, undoable: false }
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, shortcut_mac: None, params: $params, enabled: $en, run: $run, journal: false, undoable: false }
|
||||
};
|
||||
(noundo $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: false }
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, shortcut_mac: None, params: $params, enabled: $en, run: $run, journal: true, undoable: false }
|
||||
};
|
||||
}
|
||||
pub(crate) use cmd;
|
||||
@@ -138,6 +165,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(format::specs());
|
||||
v.extend(sheet::specs());
|
||||
v.extend(insert::specs());
|
||||
v.extend(equation::specs());
|
||||
v.extend(data::specs());
|
||||
v.extend(formulas::specs());
|
||||
v.extend(review::specs());
|
||||
@@ -149,6 +177,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(spelling::specs());
|
||||
v.extend(solver::specs());
|
||||
v.extend(pivot::specs());
|
||||
v.extend(python::specs());
|
||||
v.extend(slicer::specs());
|
||||
v.extend(whatif::specs());
|
||||
v.extend(forecast::specs());
|
||||
@@ -180,6 +209,7 @@ pub fn register_test_command(id: &'static str, run: Run) {
|
||||
label: id,
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
shortcut_mac: None,
|
||||
params: "",
|
||||
enabled: |_| Ok(()),
|
||||
run,
|
||||
@@ -197,6 +227,7 @@ pub(crate) fn register_undoable_test_command(id: &'static str, run: Run) {
|
||||
label: id,
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
shortcut_mac: None,
|
||||
params: "",
|
||||
enabled: |_| Ok(()),
|
||||
run,
|
||||
@@ -374,6 +405,9 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
|
||||
};
|
||||
let r = f(&mut ctx)?;
|
||||
let Ctx { mut wb, changed, structural, mut fit_rows, protection_checked, edits, .. } = ctx;
|
||||
// Equations whose source changed (or that arrived without a layout, as from a paste) are
|
||||
// laid out here, so painting only ever reads the cache.
|
||||
wb.refresh_equations();
|
||||
if !protection_checked {
|
||||
check_protection(&d.wb, &wb, &d.unlocked_ranges)?;
|
||||
}
|
||||
@@ -490,7 +524,7 @@ fn check_arrays(old: &Workbook, new: &Workbook) -> Result<()> {
|
||||
continue;
|
||||
}
|
||||
for c in sh.cells.diff(&after.cells) {
|
||||
let filled = after.cell(c).is_some_and(|x| x.formula.is_some() || !x.value.is_empty());
|
||||
let filled = after.cell(c).is_some_and(gridcraft_model::Cell::has_content);
|
||||
if !filled || !sh.spill.contains_key(&c) || same_content(sh.cell(c), after.cell(c)) {
|
||||
continue;
|
||||
}
|
||||
@@ -506,13 +540,18 @@ fn check_arrays(old: &Workbook, new: &Workbook) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Two cells hold the same value or formula (formats aside).
|
||||
/// Two cells hold the same value, formula or Python code (formats aside; a Python cell's result
|
||||
/// is not something the person edits).
|
||||
fn same_content(a: Option<&gridcraft_model::Cell>, b: Option<&gridcraft_model::Cell>) -> bool {
|
||||
let empty = |c: Option<&gridcraft_model::Cell>| c.is_none_or(|c| c.formula.is_none() && c.value.is_empty());
|
||||
let empty = |c: Option<&gridcraft_model::Cell>| c.is_none_or(|c| !c.has_content());
|
||||
match (a, b) {
|
||||
(Some(a), Some(b)) => match (&a.formula, &b.formula) {
|
||||
(Some(x), Some(y)) => x.text == y.text && x.array == y.array,
|
||||
(None, None) => a.value == b.value,
|
||||
(None, None) => match (&a.py, &b.py) {
|
||||
(Some(x), Some(y)) => x.code == y.code && x.object == y.object,
|
||||
(None, None) => a.value == b.value,
|
||||
_ => false,
|
||||
},
|
||||
_ => false,
|
||||
},
|
||||
_ => empty(a) && empty(b),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -99,18 +99,30 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
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!(
|
||||
"data.group",
|
||||
"Group",
|
||||
["Data", "Outline"],
|
||||
Some("Alt+Shift+Right"),
|
||||
"{rows?: \"2:5\" | cols?: \"B:C\" | by?: rows|cols (the selection's rows or columns; a block of cells groups rows by default), sheet?}",
|
||||
has_doc,
|
||||
|s, p| group(s, p, true)
|
||||
),
|
||||
cmd!("data.ungroup", "Ungroup", ["Data", "Outline"], Some("Alt+Shift+Left"), "{rows? | cols? | by?: rows|cols, sheet?}", has_doc, |s, p| {
|
||||
group(s, p, false)
|
||||
}),
|
||||
CommandSpec {
|
||||
shortcut_mac: Some("Cmd+Shift+K"),
|
||||
..cmd!(
|
||||
"data.group",
|
||||
"Group",
|
||||
["Data", "Outline"],
|
||||
Some("Alt+Shift+Right"),
|
||||
"{rows?: \"2:5\" | cols?: \"B:C\" | by?: rows|cols (the selection's rows or columns; a block of cells groups rows by default), sheet?}",
|
||||
has_doc,
|
||||
|s, p| group(s, p, true)
|
||||
)
|
||||
},
|
||||
CommandSpec {
|
||||
shortcut_mac: Some("Cmd+Shift+J"),
|
||||
..cmd!(
|
||||
"data.ungroup",
|
||||
"Ungroup",
|
||||
["Data", "Outline"],
|
||||
Some("Alt+Shift+Left"),
|
||||
"{rows? | cols? | by?: rows|cols, sheet?}",
|
||||
has_doc,
|
||||
|s, p| { group(s, p, false) }
|
||||
)
|
||||
},
|
||||
cmd!(
|
||||
"data.hideDetail",
|
||||
"Hide Detail",
|
||||
|
||||
@@ -195,8 +195,7 @@ fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec<S
|
||||
// The object's own geometry (its top-left anchor cell, offset and size) is read from the
|
||||
// pre-sort `sh`; a two-cell object's bottom-right cell is derived from its size here so it
|
||||
// can be re-fitted to the lines it spans after the sort.
|
||||
let objs: Vec<Anchor> =
|
||||
sh.images.iter().map(|o| o.anchor).chain(sh.charts.iter().map(|o| o.anchor)).chain(sh.shapes.iter().map(|o| o.anchor)).collect();
|
||||
let objs: Vec<Anchor> = sh.object_anchors().copied().collect();
|
||||
let descs: Vec<(u32, u32, f32, f32)> = objs
|
||||
.iter()
|
||||
.map(|a| {
|
||||
@@ -242,33 +241,22 @@ fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec<S
|
||||
}
|
||||
}
|
||||
}
|
||||
// Re-anchor floating objects now that the row heights are final. `objs` was collected as
|
||||
// images ++ charts ++ shapes, so the same index maps straight onto each vec.
|
||||
// Re-anchor floating objects now that the row heights are final. `objs` was collected in
|
||||
// `object_anchors` order, which `object_anchors_mut` repeats.
|
||||
// Only objects anchored inside the sorted block move: within its lines on the sort axis
|
||||
// and within its span on the other axis (a picture beside the block stays put).
|
||||
let (b0, b1) = if by_cols { (body.start.col, body.end.col) } else { (body.start.row, body.end.row) };
|
||||
let (o0, o1) = if by_cols { (body.start.row, body.end.row) } else { (body.start.col, body.end.col) };
|
||||
let ni = shm.images.len();
|
||||
let nch = shm.charts.len();
|
||||
for (i, (a, desc)) in objs.into_iter().zip(descs).enumerate() {
|
||||
let other = if by_cols { a.cell.row } else { a.cell.col };
|
||||
if other < o0 || other > o1 {
|
||||
continue;
|
||||
}
|
||||
let na = sorted_anchor(shm, a, desc, by_cols, b0, b1, &dst_of);
|
||||
if na != a {
|
||||
if i < ni {
|
||||
if let Some(o) = shm.images.get_mut(i) {
|
||||
o.anchor = na;
|
||||
}
|
||||
} else if i < ni + nch {
|
||||
if let Some(o) = shm.charts.get_mut(i - ni) {
|
||||
o.anchor = na;
|
||||
}
|
||||
} else if let Some(o) = shm.shapes.get_mut(i - ni - nch) {
|
||||
o.anchor = na;
|
||||
}
|
||||
}
|
||||
let new_anchors: Vec<Anchor> = objs
|
||||
.into_iter()
|
||||
.zip(descs)
|
||||
.map(|(a, desc)| {
|
||||
let other = if by_cols { a.cell.row } else { a.cell.col };
|
||||
if other < o0 || other > o1 { a } else { sorted_anchor(shm, a, desc, by_cols, b0, b1, &dst_of) }
|
||||
})
|
||||
.collect();
|
||||
for (a, n) in shm.object_anchors_mut().zip(new_anchors) {
|
||||
*a = n;
|
||||
}
|
||||
for (c, _) in moved {
|
||||
cx.changed.push((sheet, c));
|
||||
@@ -284,7 +272,9 @@ fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec<S
|
||||
/// line index on the moving axis and is the identity outside `b0..=b1`. `sh` is the post-sort
|
||||
/// grid (row heights already reordered), so re-fitting uses the heights the object lands on.
|
||||
/// Objects pinned absolutely ("Don't move or size with cells") never move; "move but don't size"
|
||||
/// keeps its size; "move and size" is re-fitted when its whole extent stays inside the body.
|
||||
/// keeps its size; "move and size" is re-fitted when its whole extent stays inside the body, and
|
||||
/// keeps its size when the sort turns its lines around (its bottom line now sorts before its top
|
||||
/// line), since the lines then enclose nothing to stretch it between.
|
||||
fn sorted_anchor(sh: &Sheet, a: Anchor, bottom: (u32, u32, f32, f32), by_cols: bool, b0: u32, b1: u32, dst_of: &[u32]) -> Anchor {
|
||||
if a.mode == AnchorMode::Absolute {
|
||||
return a;
|
||||
@@ -302,7 +292,10 @@ fn sorted_anchor(sh: &Sheet, a: Anchor, bottom: (u32, u32, f32, f32), by_cols: b
|
||||
let y1 = sh.row_top(nr) + a.dy as f64;
|
||||
let x2 = sh.col_left(nbc) + tdx as f64;
|
||||
let y2 = sh.row_top(nbr) + tdy as f64;
|
||||
Anchor { cell: CellRef::new(nr, nc), dx: a.dx, dy: a.dy, width: (x2 - x1).max(0.0) as f32, height: (y2 - y1).max(0.0) as f32, mode: a.mode }
|
||||
// An extent that is not positive means the sort put the bottom-right line before the
|
||||
// top-left one on that axis: the object keeps its old size there.
|
||||
let fit = |extent: f64, old: f32| if extent > 0.0 { extent as f32 } else { old.max(0.0) };
|
||||
Anchor { cell: CellRef::new(nr, nc), dx: a.dx, dy: a.dy, width: fit(x2 - x1, a.width), height: fit(y2 - y1, a.height), mode: a.mode }
|
||||
} else {
|
||||
Anchor { cell: CellRef::new(nr, nc), ..a }
|
||||
}
|
||||
|
||||
@@ -146,7 +146,16 @@ pub(super) fn text_to_columns(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
for (c, t) in out {
|
||||
let cell = crate::cmd::edit::input_to_cell(&t, sheet, c, &mut cx.wb).unwrap_or(None);
|
||||
let cell = match crate::cmd::edit::input_to_cell(&t, sheet, c, &mut cx.wb) {
|
||||
Ok(cell) => cell,
|
||||
// `=PY(…)` doesn't make a Python cell (that takes python.set): the piece is kept
|
||||
// as text rather than lost.
|
||||
Err(_) if gridcraft_model::python::is_py_formula(&t) => {
|
||||
let style = cx.wb.sheet(sheet).map(|sh| sh.style_id(c)).unwrap_or_default();
|
||||
Some(Cell { value: gridcraft_core::Value::text(t.as_str()), formula: None, style, py: None })
|
||||
}
|
||||
Err(_) => None,
|
||||
};
|
||||
let shm = cx.sheet_mut(sheet)?;
|
||||
match cell {
|
||||
Some(x) => shm.set_cell(c, x),
|
||||
|
||||
@@ -80,6 +80,7 @@ fn stroke(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let text = pts.iter().map(|q| format!("{:.1},{:.1}", q[0] - minx, q[1] - miny)).collect::<Vec<_>>().join(";");
|
||||
let id = d.wb.next_object_id();
|
||||
let shape = Shape {
|
||||
math: Default::default(),
|
||||
id,
|
||||
kind: ShapeKind::Ink,
|
||||
anchor: Anchor {
|
||||
|
||||
@@ -35,7 +35,7 @@ use go_to::go_to_special;
|
||||
#[cfg(test)]
|
||||
use go_to::{GO_TO_MAX_AREAS, GO_TO_RECENT, reference_parts};
|
||||
use paste_special::paste_special;
|
||||
pub(crate) use paste_special::rewrite_all_formulas;
|
||||
pub(crate) use paste_special::{rewrite_all_formulas, rewrite_cell_links, rewrite_python_refs};
|
||||
use selection::{select_all, select_col, select_region, select_row, selection_move, selection_next, selection_set};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -81,15 +81,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
paste
|
||||
),
|
||||
cmd!(
|
||||
"edit.pasteSpecial",
|
||||
"Paste Special…",
|
||||
["Home", "Clipboard"],
|
||||
Some("Ctrl+Cmd+V"),
|
||||
"{what: all|formulas|values|formats|comments|validation|allExceptBorders|columnWidths|formulasAndNumberFormats|valuesAndNumberFormats, operation?: none|add|subtract|multiply|divide, skipBlanks?, transpose?, link?}",
|
||||
has_clipboard,
|
||||
paste_special
|
||||
),
|
||||
CommandSpec {
|
||||
shortcut_mac: Some("Ctrl+Cmd+V"),
|
||||
..cmd!(
|
||||
"edit.pasteSpecial",
|
||||
"Paste Special…",
|
||||
["Home", "Clipboard"],
|
||||
Some("Ctrl+Alt+V"),
|
||||
"{what: all|formulas|values|formats|comments|validation|allExceptBorders|columnWidths|formulasAndNumberFormats|valuesAndNumberFormats, operation?: none|add|subtract|multiply|divide, skipBlanks?, transpose?, link?}",
|
||||
has_clipboard,
|
||||
paste_special
|
||||
)
|
||||
},
|
||||
cmd!(noundo "edit.clearClipboard", "Cancel Copy", [], Some("Escape"), "{}", has_doc, clear_clipboard),
|
||||
cmd!("edit.clearAll", "Clear All", ["Home", "Editing", "Clear"], None, "{range?}", has_doc, |s, p| clear(s, p, "all")),
|
||||
cmd!("edit.clearFormats", "Clear Formats", ["Home", "Editing", "Clear"], None, "{range?}", has_doc, |s, p| clear(s, p, "formats")),
|
||||
@@ -129,7 +132,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Replace…",
|
||||
["Home", "Editing", "Find & Select"],
|
||||
Some("Ctrl+H"),
|
||||
"{what, with, matchCase?, wholeCell?, all?: true, within?: sheet|workbook}",
|
||||
"{what, with, matchCase?, wholeCell?, all?: true, within?: sheet|workbook} → {replaced, skipped?} (in a Python cell the code is edited; skipped counts Python cells whose code would no longer fit)",
|
||||
has_doc,
|
||||
replace
|
||||
),
|
||||
@@ -264,6 +267,7 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
|
||||
for (_, cell) in cells.iter_mut() {
|
||||
let checkbox = cx.wb.styles.get(cell.style).num_fmt.as_str() == "checkbox";
|
||||
cell.formula = None;
|
||||
cell.py = None;
|
||||
if checkbox && uncheck_only {
|
||||
cell.value = Value::Bool(false);
|
||||
} else {
|
||||
|
||||
@@ -5,6 +5,9 @@ use super::{
|
||||
|
||||
// ---------------------------------------------------------------- entry
|
||||
|
||||
/// Typing `=PY(` doesn't make a Python cell; this says what does.
|
||||
pub(crate) const PY_FORMULA: &str = "Python cells are made with the python.set command (the code as `code`), not by typing =PY(…).";
|
||||
|
||||
/// Whether typing `input` into a cell enters a formula: `=…`, or `+…`/`-…` followed by a name or
|
||||
/// a parenthesis (not a number), as Excel takes `+SUM(1)`.
|
||||
pub(crate) fn is_formula_input(input: &str) -> bool {
|
||||
@@ -21,14 +24,18 @@ pub(crate) fn is_formula_input(input: &str) -> bool {
|
||||
pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gridcraft_model::Workbook) -> Result<Option<Cell>> {
|
||||
let style = wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
|
||||
if input.is_empty() {
|
||||
let c = Cell { value: Value::Empty, formula: None, style };
|
||||
let c = Cell { value: Value::Empty, formula: None, style, py: None };
|
||||
return Ok(if c.is_blank() { None } else { Some(c) });
|
||||
}
|
||||
let fmt_is_text = wb.styles.get(style).num_fmt.as_str() == "@";
|
||||
if fmt_is_text {
|
||||
return Ok(Some(Cell { value: Value::text(input), formula: None, style }));
|
||||
return Ok(Some(Cell { value: Value::text(input), formula: None, style, py: None }));
|
||||
}
|
||||
if is_formula_input(input) {
|
||||
let is_formula = is_formula_input(input);
|
||||
if is_formula && gridcraft_model::python::is_py_formula(input) {
|
||||
return Err(EngineError::Other(PY_FORMULA.into()));
|
||||
}
|
||||
if is_formula {
|
||||
let body = input.strip_prefix('=').unwrap_or(input);
|
||||
let expr = wb.name_call_case(parse_typed_formula(body)?);
|
||||
check_arg_counts(&expr)?;
|
||||
@@ -79,7 +86,7 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri
|
||||
if input.contains('\n') && !wb.styles.get(style).align.wrap {
|
||||
style = wb.styles.derive(style, |s| s.align.wrap = true);
|
||||
}
|
||||
Ok(Some(Cell { value: parsed.value, formula: None, style }))
|
||||
Ok(Some(Cell { value: parsed.value, formula: None, style, py: None }))
|
||||
}
|
||||
|
||||
/// Parses a formula as it is typed (without the `=`). Excel closes missing parentheses for you,
|
||||
@@ -147,7 +154,7 @@ pub(super) fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
// The array takes the whole range: other cells in it lose their contents.
|
||||
for c in range.iter().skip(1).take(1_000_000) {
|
||||
if let Some(old) = sh.cells.get(c).filter(|x| !x.value.is_empty() || x.formula.is_some()) {
|
||||
if let Some(old) = sh.cells.get(c).filter(|x| x.has_content()) {
|
||||
let style = old.style;
|
||||
sh.set_cell(c, Cell { style, ..Cell::default() });
|
||||
}
|
||||
@@ -239,10 +246,14 @@ pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
value: Value::number(n.as_f64().unwrap_or(0.0)),
|
||||
formula: None,
|
||||
style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(),
|
||||
py: None,
|
||||
}),
|
||||
Json::Bool(b) => Some(Cell {
|
||||
value: Value::Bool(*b),
|
||||
formula: None,
|
||||
style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(),
|
||||
py: None,
|
||||
}),
|
||||
Json::Bool(b) => {
|
||||
Some(Cell { value: Value::Bool(*b), formula: None, style: cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default() })
|
||||
}
|
||||
// Text as given: no date or number reading, and a leading `'` is part of the
|
||||
// text rather than the marker that makes it text. A formula is still a
|
||||
// formula, which is what the ticket asks for and what `=` means in a cell.
|
||||
@@ -258,7 +269,7 @@ pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if text.contains('\n') && !cx.wb.styles.get(style).align.wrap {
|
||||
style = cx.wb.styles.derive(style, |s| s.align.wrap = true);
|
||||
}
|
||||
Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style })
|
||||
Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style, py: None })
|
||||
}
|
||||
}
|
||||
_ => input_to_cell(&input, sheet, at, &mut cx.wb)?,
|
||||
|
||||
@@ -92,7 +92,7 @@ fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRe
|
||||
for c in cells {
|
||||
let Some(to) = c.offset(dr, dc) else { continue };
|
||||
let style = src.cell(c).map(|x| x.style).unwrap_or_default();
|
||||
dst.set_cell(to, Cell { value: src.value(c), formula: None, style });
|
||||
dst.set_cell(to, Cell { value: src.value(c), formula: None, style, py: None });
|
||||
if let Some(picture) = src.cell_pictures.get(&c) {
|
||||
dst.cell_pictures.insert(to, picture.clone());
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let all = bool_param(p, "all").unwrap_or(true);
|
||||
let workbook = str_param(p, "within") == Some("workbook");
|
||||
let active = s.doc()?.selection.active;
|
||||
let mut skipped = 0usize;
|
||||
let n = edit(s, |cx| {
|
||||
let sheets: Vec<usize> = if workbook { (0..cx.wb.sheets.len()).collect() } else { vec![cx.wb.active_sheet()] };
|
||||
let mut n = 0;
|
||||
@@ -135,6 +136,30 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
} else {
|
||||
replace_ci(&text, &what, &with)
|
||||
};
|
||||
// A Python cell's text is its code: the replacement edits the code and the cell
|
||||
// stays a Python cell. Code that would no longer fit in a PY formula is left as it was.
|
||||
let old_py = cx.wb.sheet(si).and_then(|sh| sh.cell(c)).and_then(|x| x.py.clone());
|
||||
if let Some(old) = old_py {
|
||||
let py = gridcraft_model::PyCode { object: old.object, ..gridcraft_model::PyCode::new(&new) };
|
||||
if py.code == old.code {
|
||||
continue;
|
||||
}
|
||||
if !py.fits() {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
// Its old spill is left to the recalculation, which removes it and
|
||||
// recalculates what read it.
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
let style = sh.style_id(c);
|
||||
sh.set_cell(
|
||||
c,
|
||||
Cell { value: Value::Error(gridcraft_core::CellError::Blocked), formula: None, style, py: Some(std::sync::Arc::new(py)) },
|
||||
);
|
||||
cx.touch(si, c);
|
||||
n += 1;
|
||||
continue;
|
||||
}
|
||||
let cell = input_to_cell(&new, si, c, &mut cx.wb).unwrap_or_else(|_| Some(Cell::value(Value::text(new.as_str()))));
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
match cell {
|
||||
@@ -149,6 +174,9 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
Ok(n)
|
||||
})?;
|
||||
if skipped > 0 {
|
||||
return Ok(json!({"replaced": n, "skipped": skipped}));
|
||||
}
|
||||
Ok(json!({"replaced": n}))
|
||||
}
|
||||
|
||||
|
||||
@@ -174,8 +174,8 @@ pub(super) fn go_to_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let v = sheet.value(c);
|
||||
let ok = match kind {
|
||||
"blanks" => v.is_empty(),
|
||||
"constants" => cell.is_some_and(|x| x.formula.is_none() && !x.value.is_empty()),
|
||||
"formulas" => cell.is_some_and(|x| x.formula.is_some()),
|
||||
"constants" => cell.is_some_and(|x| x.formula.is_none() && x.py.is_none() && !x.value.is_empty()),
|
||||
"formulas" => cell.is_some_and(|x| x.formula.is_some() || x.py.is_some()),
|
||||
"comments" | "notes" => sheet.comments.contains_key(&c),
|
||||
"errors" => v.is_error(),
|
||||
"numbers" => v.is_number(),
|
||||
|
||||
@@ -145,6 +145,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
moved_formulas = taken;
|
||||
}
|
||||
let dest_name = cx.wb.sheet(dest_sheet).map(|sh| sh.name.clone()).unwrap_or_default();
|
||||
for (tr, tc, r, c) in tiled_cells(src.height(), src.width(), tiles_r, tiles_c) {
|
||||
let sc = CellRef::new(src.start.row + r, src.start.col + c);
|
||||
let (dr, dc) = if transpose { (c, r) } else { (r, c) };
|
||||
@@ -152,7 +153,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let src_cell = if cut_move { moved_formulas.iter().find(|(k, _)| *k == sc).map(|(_, v)| v.clone()) } else { src_sheet.cell(sc).cloned() };
|
||||
let src_val = src_sheet.value(sc);
|
||||
let src_picture = if cut_move { moved_pictures.get(&sc) } else { src_sheet.cell_pictures.get(&sc) }.cloned();
|
||||
if skip_blanks && src_picture.is_none() && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none()) {
|
||||
if skip_blanks && src_picture.is_none() && src_val.is_empty() && src_cell.as_ref().is_none_or(|x| x.formula.is_none() && x.py.is_none()) {
|
||||
continue;
|
||||
}
|
||||
let old = cx.wb.sheet(dest_sheet).and_then(|sh| sh.cell(dest)).cloned().unwrap_or_default();
|
||||
@@ -186,6 +187,33 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let moved = gridcraft_formula::adjust::shift_relative(e, dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
|
||||
Some(std::sync::Arc::new(Formula { dynamic: f.dynamic, ..Formula::from_expr(moved) }))
|
||||
};
|
||||
// A Python cell's code travels with it. Cut to another sheet, it keeps reading the
|
||||
// cells its `xl()` references named on its original sheet, as a moved formula does:
|
||||
// they are qualified with that sheet before the workbook-wide move adjustment below.
|
||||
let moved_py = |cell: &Cell| -> Result<Option<std::sync::Arc<gridcraft_model::PyCode>>> {
|
||||
let Some(py) = cell.py.clone() else { return Ok(None) };
|
||||
if !cut_move || dest_sheet == clip.sheet {
|
||||
return Ok(Some(py));
|
||||
}
|
||||
let qualified = gridcraft_model::python::rewrite_xl(&py.code, |reference| {
|
||||
let e = gridcraft_formula::parse(reference).ok()?.map(&mut |x| match x {
|
||||
gridcraft_formula::Expr::Ref(mut r) if r.sheet == gridcraft_formula::SheetSel::Current => {
|
||||
r.sheet = gridcraft_formula::SheetSel::Named(src_sheet_name.clone());
|
||||
gridcraft_formula::Expr::Ref(r)
|
||||
}
|
||||
other => other,
|
||||
});
|
||||
Some(gridcraft_formula::print(&e))
|
||||
});
|
||||
Ok(Some(match qualified {
|
||||
Some(code) => {
|
||||
let moved = gridcraft_model::PyCode { code, ..(*py).clone() };
|
||||
check_python_fits(&moved, &dest_name, dest)?;
|
||||
std::sync::Arc::new(moved)
|
||||
}
|
||||
None => py,
|
||||
}))
|
||||
};
|
||||
if link {
|
||||
let sheet_prefix = if dest_sheet == clip.sheet && same_doc {
|
||||
String::new()
|
||||
@@ -194,6 +222,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
};
|
||||
new.formula = Some(std::sync::Arc::new(Formula::new(&format!("={sheet_prefix}{}", sc.a1()))));
|
||||
new.value = Value::Empty;
|
||||
new.py = None;
|
||||
new_picture = None;
|
||||
} else {
|
||||
let sc_cell = src_cell.clone().unwrap_or_default();
|
||||
@@ -202,6 +231,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
new_picture = src_picture.clone();
|
||||
new.formula = shift(&sc_cell);
|
||||
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
|
||||
new.py = moved_py(&sc_cell)?;
|
||||
let st = map_style(sc_cell.style, &mut cx.wb);
|
||||
new.style = if what == What::AllExceptBorders {
|
||||
let borders = cx.wb.styles.get(old.style).borders;
|
||||
@@ -214,6 +244,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
new_picture = src_picture.clone();
|
||||
new.formula = shift(&sc_cell);
|
||||
new.value = if new.formula.is_some() { Value::Empty } else { sc_cell.value.clone() };
|
||||
new.py = moved_py(&sc_cell)?;
|
||||
if what == What::FormulasAndNumberFormats {
|
||||
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
|
||||
new.style = cx.wb.styles.derive(old.style, |s| s.num_fmt = nf);
|
||||
@@ -222,6 +253,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
What::Values | What::ValuesAndNumberFormats => {
|
||||
new_picture = src_picture.clone();
|
||||
new.formula = None;
|
||||
new.py = None;
|
||||
new.value = src_val.clone();
|
||||
if what == What::ValuesAndNumberFormats {
|
||||
let nf = src_wb.styles.get(sc_cell.style).num_fmt.clone();
|
||||
@@ -232,7 +264,11 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
What::Comments | What::Validation | What::ColumnWidths => {}
|
||||
}
|
||||
// Operations combine numbers with what is already there.
|
||||
if op != "none" && matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas) && new.formula.is_none() {
|
||||
if op != "none"
|
||||
&& matches!(what, What::All | What::Values | What::ValuesAndNumberFormats | What::Formulas)
|
||||
&& new.formula.is_none()
|
||||
&& new.py.is_none()
|
||||
{
|
||||
if old_picture.is_some() || new_picture.is_some() {
|
||||
new_picture = None;
|
||||
new.value = Value::Error(gridcraft_core::CellError::Value);
|
||||
@@ -292,7 +328,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
} else {
|
||||
gridcraft_formula::adjust::Edit::MoveToSheet { from: src, to_sheet: dest_name, to_row: at.row, to_col: at.col }
|
||||
};
|
||||
rewrite_all_formulas(&mut cx.wb, &src_sheet_name, &e);
|
||||
rewrite_all_formulas(&mut cx.wb, &src_sheet_name, &e)?;
|
||||
cx.structural = true;
|
||||
}
|
||||
let end = at.offset_clamped((tiles_r * h) as i64 - 1, (tiles_c * w) as i64 - 1);
|
||||
@@ -316,7 +352,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
/// Applies a reference adjustment to every cell formula and defined name in the workbook.
|
||||
pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &str, e: &gridcraft_formula::adjust::Edit) {
|
||||
pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &str, e: &gridcraft_formula::adjust::Edit) -> Result<()> {
|
||||
use gridcraft_formula::adjust::Edit;
|
||||
// Rows and columns inserted or deleted on the target sheet move its legacy array ranges too
|
||||
// (a cut moves its arrays itself).
|
||||
@@ -355,4 +391,109 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &
|
||||
n.formula = gridcraft_formula::print(&new);
|
||||
}
|
||||
}
|
||||
rewrite_cell_links(wb, |host, expr| gridcraft_formula::adjust::adjust(expr, host, target, e));
|
||||
rewrite_python_refs(wb, &|host, expr| gridcraft_formula::adjust::adjust(expr, host, target, e))
|
||||
}
|
||||
|
||||
/// Runs `f` over every reference a floating object holds (a chart's series values, categories,
|
||||
/// name and bubble sizes, and the source block it was made from). Every edit that moves
|
||||
/// references calls it: inserting and deleting lines and cells and cut-moves (through
|
||||
/// [`rewrite_all_formulas`]), and renaming and deleting sheets. `f` maps a reference held by an
|
||||
/// object on the sheet named `host` to its new form; text that does not parse is kept. A source
|
||||
/// block that no longer exists is dropped.
|
||||
pub(crate) fn rewrite_cell_links(wb: &mut gridcraft_model::Workbook, f: impl Fn(&str, gridcraft_formula::Expr) -> gridcraft_formula::Expr) {
|
||||
for si in 0..wb.sheets.len() {
|
||||
let Some(host) = wb.sheets.get(si).filter(|s| !s.charts.is_empty()).map(|s| s.name.clone()) else { continue };
|
||||
let Some(sh) = wb.sheet_mut(si) else { continue };
|
||||
let fix = |text: &str| -> String {
|
||||
match gridcraft_formula::parse(text) {
|
||||
Ok(expr) => gridcraft_formula::print(&f(&host, expr)),
|
||||
Err(_) => text.to_string(),
|
||||
}
|
||||
};
|
||||
for ch in sh.charts.iter_mut() {
|
||||
ch.source = ch.source.as_deref().map(fix).filter(|s| !s.contains("#REF!"));
|
||||
for se in ch.series.iter_mut() {
|
||||
se.values = fix(&se.values);
|
||||
se.categories = se.categories.as_deref().map(fix);
|
||||
se.name = se.name.as_deref().map(fix);
|
||||
se.bubble_sizes = se.bubble_sizes.as_deref().map(fix);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrites the `xl("…")` literals in every Python cell with `f`, which maps a reference held on
|
||||
/// sheet `host` to its new form. Every reference-moving edit (insert and delete of lines or cells,
|
||||
/// cut-move, sheet rename and delete) runs it, as it adjusts formulas. A literal is only replaced
|
||||
/// when the reference it names changed, and text that doesn't parse is kept. A cell's cached
|
||||
/// result is kept while its references still name the same data (a rename, a move); a reference
|
||||
/// the edit deleted (`#REF!`), or one that grew or shrank (rows or columns inserted or deleted
|
||||
/// inside it), leaves the result stale, so the cell is new code (`#BLOCKED!`) and the
|
||||
/// recalculation removes its spill. A rewrite that would
|
||||
/// make a cell's code longer than one cell holds (a longer sheet name, `Z1` becoming `AA1`) refuses
|
||||
/// the whole edit, which then changes nothing: see [`check_python_fits`].
|
||||
pub(crate) fn rewrite_python_refs(
|
||||
wb: &mut gridcraft_model::Workbook,
|
||||
f: &impl Fn(&str, gridcraft_formula::Expr) -> gridcraft_formula::Expr,
|
||||
) -> Result<()> {
|
||||
for si in 0..wb.sheets.len() {
|
||||
let Some(sh) = wb.sheets.get(si) else { continue };
|
||||
let host = sh.name.clone();
|
||||
let mut new_code: Vec<(CellRef, String, bool)> = Vec::new();
|
||||
for (c, cell) in sh.cells.iter() {
|
||||
let Some(py) = &cell.py else { continue };
|
||||
let mut lost = false;
|
||||
let changed = gridcraft_model::python::rewrite_xl(&py.code, |reference| {
|
||||
let expr = gridcraft_formula::parse(reference).ok()?;
|
||||
let before = gridcraft_formula::print(&expr);
|
||||
let after = gridcraft_formula::print(&f(&host, expr));
|
||||
lost |= (after.contains("#REF!") && !before.contains("#REF!")) || area_shape(&before) != area_shape(&after);
|
||||
(after != before).then_some(after)
|
||||
});
|
||||
if let Some(code) = changed {
|
||||
check_python_fits(&gridcraft_model::PyCode { code: code.clone(), object: py.object, ..Default::default() }, &host, c)?;
|
||||
new_code.push((c, code, lost));
|
||||
}
|
||||
}
|
||||
if new_code.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(sh) = wb.sheet_mut(si) else { continue };
|
||||
for (c, code, lost) in new_code {
|
||||
if let Some(cell) = sh.cells.get_mut(c)
|
||||
&& let Some(py) = cell.py.as_mut()
|
||||
{
|
||||
let py = std::sync::Arc::make_mut(py);
|
||||
py.code = code;
|
||||
if lost {
|
||||
py.output = None;
|
||||
cell.value = Value::Error(gridcraft_core::CellError::Blocked);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The rows and columns a printed reference covers (`A1:B3`, `Sheet2!$A$1`, `C:E`), or `None`
|
||||
/// for any other form (a name, an error).
|
||||
fn area_shape(reference: &str) -> Option<(u32, u32)> {
|
||||
let r = RangeRef::parse(reference.rsplit('!').next()?)?;
|
||||
Some((r.height(), r.width()))
|
||||
}
|
||||
|
||||
/// Refuses Python code an edit has made longer than one cell holds: the limit `python.set`
|
||||
/// enforces and opening a file applies (code over it is dropped to its value on open). The edit
|
||||
/// is refused rather than made, so no code is lost and none keeps a reference the edit moved.
|
||||
pub(crate) fn check_python_fits(py: &gridcraft_model::PyCode, sheet: &str, at: CellRef) -> Result<()> {
|
||||
if py.fits() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(EngineError::Other(format!(
|
||||
"The edit would make the Python code in {}!{} longer than Excel's {}-character formula limit, so nothing was changed. Shorten the code first.",
|
||||
gridcraft_formula::quote_sheet(sheet),
|
||||
at.a1(),
|
||||
gridcraft_model::python::MAX_FORMULA_CHARS
|
||||
)))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
//! Equations (Insert › Symbols › Equation): floating shapes holding LaTeX source, laid out by
|
||||
//! `gridcraft-math` and drawn from the cached layout.
|
||||
//!
|
||||
//! An equation is drawn to fit its frame, so resizing the frame scales it. Inserting sizes the
|
||||
//! frame for the requested font size; editing keeps the current scale and resizes the frame to
|
||||
//! the new content.
|
||||
|
||||
use gridcraft_math::{MathError, MathList};
|
||||
use gridcraft_model::{Anchor, AnchorMode, Color, EquationCache, Shape, ShapeKind};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::{CommandSpec, EngineError, PROTECTED, Result, Session, bad, cell_param, cmd, edit, f64_param, has_doc, ok, str_param, u32_param};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"insert.equation",
|
||||
"Equation",
|
||||
["Insert", "Symbols"],
|
||||
None,
|
||||
"{latex: \"x^2\", cell?|at?: \"B2\", size?: 11 (points), color?: hex|\"auto\"}",
|
||||
has_doc,
|
||||
insert_equation
|
||||
),
|
||||
cmd!("equation.edit", "Edit Equation", [], None, "{id, latex}", has_doc, edit_equation),
|
||||
]
|
||||
}
|
||||
|
||||
/// Default equation font size, in points.
|
||||
pub const DEFAULT_SIZE_PT: f64 = 11.0;
|
||||
/// Space between an equation's frame and its content, in sheet pixels.
|
||||
pub const PADDING: f32 = 4.0;
|
||||
/// Sheet pixels per point.
|
||||
const PX_PER_PT: f64 = 96.0 / 72.0;
|
||||
|
||||
/// The scale (sheet pixels per em) at which `list` fits a frame of `w` by `h` pixels.
|
||||
pub fn fit_scale(list: &MathList, w: f32, h: f32) -> f32 {
|
||||
let (cw, ch) = (list.width.max(0.01), list.total_height().max(0.01));
|
||||
let s = ((w - 2.0 * PADDING) / cw).min((h - 2.0 * PADDING) / ch);
|
||||
if s.is_finite() { s.max(0.0) } else { 0.0 }
|
||||
}
|
||||
|
||||
/// The frame size for `list` at `scale` pixels per em.
|
||||
fn frame_for(list: &MathList, scale: f32) -> (f32, f32) {
|
||||
let w = list.width.max(0.5) * scale + 2.0 * PADDING;
|
||||
let h = list.total_height().max(1.0) * scale + 2.0 * PADDING;
|
||||
(w.clamp(8.0, 20_000.0), h.clamp(8.0, 20_000.0))
|
||||
}
|
||||
|
||||
/// `latex` laid out, or a readable error with the position of the problem.
|
||||
fn lay_out(cmd: &str, latex: &str) -> Result<MathList> {
|
||||
gridcraft_math::layout(latex).map_err(|e| bad(cmd, describe(latex, &e)))
|
||||
}
|
||||
|
||||
/// "Error at character 5: Undefined control sequence: \foo".
|
||||
pub fn describe(latex: &str, e: &MathError) -> String {
|
||||
match e.char_pos(latex) {
|
||||
Some(p) => format!("equation error at character {p}: {}", e.message),
|
||||
None => format!("equation error: {}", e.message),
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_equation(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
const CMD: &str = "insert.equation";
|
||||
let latex = str_param(p, "latex").ok_or_else(|| bad(CMD, "`latex` must be the equation's LaTeX source, such as \"x^2 + y^2 = r^2\""))?;
|
||||
if latex.trim().is_empty() {
|
||||
return Err(bad(CMD, "`latex` is empty"));
|
||||
}
|
||||
let size = f64_param(p, "size").unwrap_or(DEFAULT_SIZE_PT);
|
||||
if !(1.0..=409.0).contains(&size) {
|
||||
return Err(bad(CMD, "`size` must be between 1 and 409 points"));
|
||||
}
|
||||
let color = match p.get("color") {
|
||||
None | Some(Json::Null) => Color::Auto,
|
||||
Some(c) => super::format::color_param(Some(c)).ok_or_else(|| bad(CMD, "`color` must be a hex colour such as \"#1F4E79\", or \"auto\""))?,
|
||||
};
|
||||
let list = lay_out(CMD, latex)?;
|
||||
let at = match cell_param(s, p, "cell")? {
|
||||
Some(c) => Some(c),
|
||||
None => cell_param(s, p, "at")?,
|
||||
};
|
||||
let d = s.doc()?;
|
||||
if d.wb.active().is_some_and(|sh| sh.is_protected()) {
|
||||
return Err(EngineError::Other(PROTECTED.into()));
|
||||
}
|
||||
let at = at.unwrap_or(d.selection.active);
|
||||
let id = d.wb.next_object_id();
|
||||
let (width, height) = frame_for(&list, (size * PX_PER_PT) as f32);
|
||||
let mut shape = Shape {
|
||||
id,
|
||||
kind: ShapeKind::Equation,
|
||||
anchor: Anchor { cell: at, dx: 0.0, dy: 0.0, width, height, mode: AnchorMode::MoveOnly },
|
||||
fill: Color::Auto,
|
||||
line: color,
|
||||
text: latex.to_string(),
|
||||
format: Default::default(),
|
||||
math: Default::default(),
|
||||
};
|
||||
shape.math.refresh(latex);
|
||||
let sheet = d.wb.active_sheet();
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?.shapes.push(shape);
|
||||
Ok(json!({"equation": id}))
|
||||
})
|
||||
}
|
||||
|
||||
fn edit_equation(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
const CMD: &str = "equation.edit";
|
||||
let id = u32_param(p, "id").ok_or_else(|| bad(CMD, "`id` must be an equation id"))?;
|
||||
let latex = str_param(p, "latex").ok_or_else(|| bad(CMD, "`latex` must be the equation's LaTeX source"))?;
|
||||
if latex.trim().is_empty() {
|
||||
return Err(bad(CMD, "`latex` is empty; delete the equation instead"));
|
||||
}
|
||||
let d = s.doc()?;
|
||||
let sheet = d.wb.active_sheet();
|
||||
let sh = d.wb.active().ok_or_else(|| bad(CMD, "no such equation"))?;
|
||||
let shape = sh.shapes.iter().find(|x| x.id == id && x.kind == ShapeKind::Equation).ok_or_else(|| bad(CMD, "no such equation"))?;
|
||||
if sh.is_protected() {
|
||||
return Err(EngineError::Other(PROTECTED.into()));
|
||||
}
|
||||
// The same source is a no-op, unless it has no layout yet (a file with more equations than
|
||||
// are laid out at once): then OK lays it out.
|
||||
if shape.text == latex && matches!(shape.math.get(latex), Some(Ok(_))) {
|
||||
return ok();
|
||||
}
|
||||
let list = lay_out(CMD, latex)?;
|
||||
// Keep the size the equation is drawn at now; without a layout, use the default size.
|
||||
let scale = match shape.math.get(&shape.text) {
|
||||
Some(Ok(old)) => fit_scale(old, shape.anchor.width, shape.anchor.height),
|
||||
_ => 0.0,
|
||||
};
|
||||
let scale = if scale > 0.1 { scale } else { (DEFAULT_SIZE_PT * PX_PER_PT) as f32 };
|
||||
let (width, height) = frame_for(&list, scale);
|
||||
edit(s, |cx| {
|
||||
let shape = cx.sheet_mut(sheet)?.shapes.iter_mut().find(|x| x.id == id).ok_or_else(|| bad(CMD, "no such equation"))?;
|
||||
shape.text = latex.to_string();
|
||||
shape.math = EquationCache::default();
|
||||
shape.math.refresh(latex);
|
||||
shape.anchor.width = width;
|
||||
shape.anchor.height = height;
|
||||
Ok(json!({"equation": id}))
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- PDF
|
||||
|
||||
/// The first [`gridcraft_math::MAX_SOURCE_BYTES`] bytes of an equation's source, cut at a char
|
||||
/// boundary. A longer source never lays out (the editor refuses it, and a file can carry one of
|
||||
/// megabytes), so what shows it as text, the sheet painter and the PDF fallback, shows this much
|
||||
/// and does no work on the rest.
|
||||
pub fn source_excerpt(text: &str) -> &str {
|
||||
let cap = gridcraft_math::MAX_SOURCE_BYTES;
|
||||
if text.len() <= cap {
|
||||
return text;
|
||||
}
|
||||
let mut end = cap;
|
||||
while end > 0 && !text.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
text.get(..end).unwrap_or("")
|
||||
}
|
||||
|
||||
/// The PDF face for a glyph: Symbol for Greek letters and operators, else the Times (or
|
||||
/// Helvetica, Courier) face for its style. `None` when no standard font can show `c`.
|
||||
fn pdf_font(c: char, face: gridcraft_math::Face) -> Option<gridcraft_pdf::Font> {
|
||||
use gridcraft_math::Face;
|
||||
use gridcraft_pdf::Font;
|
||||
let text = match face {
|
||||
Face::Italic => Font::TimesItalic,
|
||||
Face::Bold => Font::TimesBold,
|
||||
Face::BoldItalic => Font::TimesBoldItalic,
|
||||
Face::Sans => Font::Helvetica,
|
||||
Face::SansBold => Font::HelveticaBold,
|
||||
Face::SansItalic => Font::HelveticaOblique,
|
||||
Face::Mono => Font::Courier,
|
||||
Face::Roman | Face::Large(_) => Font::TimesRoman,
|
||||
};
|
||||
// Letters and digits stay in the text face; everything Symbol has goes to Symbol, whose
|
||||
// operators (minus, equals, plus) match the maths better than the text faces'.
|
||||
if c.is_ascii_alphanumeric() {
|
||||
return Some(text);
|
||||
}
|
||||
if Font::Symbol.can_encode(c) {
|
||||
return Some(Font::Symbol);
|
||||
}
|
||||
text.can_encode(c).then_some(text)
|
||||
}
|
||||
|
||||
/// Draws an equation's layout into the box `(x, y, w, h)` (page points) of a PDF page, with
|
||||
/// `pad` points of padding. Glyphs no standard font has are drawn as `[?]` boxes.
|
||||
pub(crate) fn draw_pdf(pg: &mut gridcraft_pdf::Page, list: &MathList, rect: (f32, f32, f32, f32), pad: f32, ink: gridcraft_pdf::Rgb) {
|
||||
use gridcraft_math::{Face, Item, PathCmd};
|
||||
let (x, y, w, h) = rect;
|
||||
let (cw, ch) = (list.width.max(0.01), list.total_height().max(0.01));
|
||||
let s = ((w - 2.0 * pad) / cw).min((h - 2.0 * pad) / ch);
|
||||
if !(s.is_finite() && s > 0.01) {
|
||||
return;
|
||||
}
|
||||
let s = s.min(4000.0);
|
||||
let (ox, oy) = (x + (w - cw * s) / 2.0, y + (h - ch * s) / 2.0);
|
||||
let col = |c: Option<[u8; 3]>| c.unwrap_or(ink);
|
||||
for item in list.items.iter().take(gridcraft_math::MAX_ITEMS) {
|
||||
match item {
|
||||
Item::Glyph(g) => {
|
||||
let color = col(g.color);
|
||||
let (gx, base) = (ox + g.x * s, oy + g.y * s);
|
||||
// The radical sign: Symbol's glyph (AFM bounding box -147 to 917 high, right edge
|
||||
// at 558, in 1/1000 em) fitted to the TeX box, so its top meets the vinculum.
|
||||
if g.ch == '\u{221A}' {
|
||||
let size = (g.height + g.depth) * s / 1.064;
|
||||
if size.is_finite() && size >= 0.5 {
|
||||
let top = base - g.height * s;
|
||||
pg.text(gx + g.width * s - 0.558 * size, top + 0.917 * size, size, gridcraft_pdf::Font::Symbol, color, "\u{221A}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let large = matches!(g.face, Face::Large(_));
|
||||
let size = if large { (g.height + g.depth) * s } else { g.size * s };
|
||||
if !(size.is_finite() && size >= 0.5) {
|
||||
continue;
|
||||
}
|
||||
let pick = pdf_font(g.ch, g.face).map(|f| (g.ch, f)).or_else(|| g.alt.and_then(|a| pdf_font(a, g.face).map(|f| (a, f))));
|
||||
match pick {
|
||||
Some((c, font)) => {
|
||||
let text = c.to_string();
|
||||
if large {
|
||||
// Centred on the glyph's box, like the screen.
|
||||
let mid = oy + (g.y - (g.height - g.depth) / 2.0) * s;
|
||||
let tw = gridcraft_pdf::text_width(font, size, &text);
|
||||
pg.text(gx + (g.width * s - tw) / 2.0, mid + 0.25 * size, size, font, color, &text);
|
||||
} else {
|
||||
pg.text(gx, base, size, font, color, &text);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// [?]: the glyph's box with a question mark.
|
||||
let (bw, bh) = ((g.width.max(0.3)) * s, (g.height + g.depth) * s);
|
||||
pg.rect_stroke(gx, base - g.height * s, bw, bh, (0.05 * s).clamp(0.2, 1.0), color);
|
||||
let qs = (bh * 0.6).min(bw * 1.2).max(1.0);
|
||||
let qw = gridcraft_pdf::text_width(gridcraft_pdf::Font::Helvetica, qs, "?");
|
||||
pg.text(gx + (bw - qw) / 2.0, base - g.height * s + (bh + qs * 0.7) / 2.0, qs, gridcraft_pdf::Font::Helvetica, color, "?");
|
||||
}
|
||||
}
|
||||
}
|
||||
Item::Rule(r) => {
|
||||
if r.width > 0.0 && r.height > 0.0 {
|
||||
pg.fill_rect(ox + r.x * s, oy + r.y * s, r.width * s, (r.height * s).max(0.25), col(r.color));
|
||||
}
|
||||
}
|
||||
Item::Path(p) => {
|
||||
let (px, py) = (ox + p.x * s, oy + p.y * s);
|
||||
let mut subs: Vec<Vec<[f32; 2]>> = vec![];
|
||||
let mut cur: Vec<[f32; 2]> = vec![];
|
||||
let at = |a: f32, b: f32| [px + a * s, py + b * s];
|
||||
for c in &p.commands {
|
||||
match *c {
|
||||
PathCmd::MoveTo(a, b) => {
|
||||
if cur.len() > 1 {
|
||||
subs.push(std::mem::take(&mut cur));
|
||||
}
|
||||
cur = vec![at(a, b)];
|
||||
}
|
||||
PathCmd::LineTo(a, b) => cur.push(at(a, b)),
|
||||
PathCmd::QuadTo(x1, y1, a, b) => {
|
||||
let p0 = cur.last().copied().unwrap_or(at(x1, y1));
|
||||
let (c1, p1) = (at(x1, y1), at(a, b));
|
||||
for k in 1..=8 {
|
||||
let t = k as f32 / 8.0;
|
||||
let u = 1.0 - t;
|
||||
cur.push([u * u * p0[0] + 2.0 * u * t * c1[0] + t * t * p1[0], u * u * p0[1] + 2.0 * u * t * c1[1] + t * t * p1[1]]);
|
||||
}
|
||||
}
|
||||
PathCmd::CubicTo(x1, y1, x2, y2, a, b) => {
|
||||
let p0 = cur.last().copied().unwrap_or(at(x1, y1));
|
||||
let (c1, c2, p1) = (at(x1, y1), at(x2, y2), at(a, b));
|
||||
for k in 1..=10 {
|
||||
let t = k as f32 / 10.0;
|
||||
let u = 1.0 - t;
|
||||
let (a3, b3, c3, d3) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
|
||||
cur.push([a3 * p0[0] + b3 * c1[0] + c3 * c2[0] + d3 * p1[0], a3 * p0[1] + b3 * c1[1] + c3 * c2[1] + d3 * p1[1]]);
|
||||
}
|
||||
}
|
||||
PathCmd::Close => {
|
||||
if cur.len() > 1 {
|
||||
subs.push(std::mem::take(&mut cur));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if cur.len() > 1 {
|
||||
subs.push(cur);
|
||||
}
|
||||
let color = col(p.color);
|
||||
for sub in subs.iter().filter(|v| v.len() <= 4000) {
|
||||
if p.fill && sub.len() >= 3 {
|
||||
// The PDF fill rule handles concave outlines itself.
|
||||
pg.polygon(sub, Some(color), None);
|
||||
} else {
|
||||
pg.polyline(sub, (0.05 * s).clamp(0.2, 1.0), color, &[]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef, Value};
|
||||
use gridcraft_model::{
|
||||
Anchor, Chart, Color, DefinedName, HAlign, LegendPos, NumFmt, Shape, Sheet, SheetViewMode, TableColumn, TotalsFn, Trendline, TrendlineKind,
|
||||
Workbook,
|
||||
Anchor, Chart, Color, DefinedName, HAlign, LegendPos, NumFmt, Shape, ShapeKind, Sheet, SheetViewMode, TableColumn, TotalsFn, Trendline,
|
||||
TrendlineKind, Workbook,
|
||||
};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
@@ -38,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Chart Element",
|
||||
["Chart Design", "Chart Layouts"],
|
||||
None,
|
||||
"{chart?, element: title|legend|dataLabels|gridlines|axisTitles|smooth|trendline, on?: bool, position?: bottom|top|left|right; trendline: series?: index (default all), type?: linear|exponential|linearForecast|movingAverage|logarithmic|power|polynomial, order?: 2..6, period?: 2..255, forward?, backward?, displayEquation?, displayRSquared?}",
|
||||
"{chart?, element: title|legend|dataLabels|dataTable|gridlines|axisTitles|smooth|trendline, on?: bool, position?: bottom|top|left|right; dataTable: dataTableKeys?: bool, dataTableHorizontalBorder?: bool, dataTableVerticalBorder?: bool, dataTableOutline?: bool; trendline: series?: index (default all), type?: linear|exponential|linearForecast|movingAverage|logarithmic|power|polynomial, order?: 2..6, period?: 2..255, forward?, backward?, displayEquation?, displayRSquared?}",
|
||||
has_doc,
|
||||
add_element
|
||||
),
|
||||
@@ -97,7 +97,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Bring Forward",
|
||||
["Page Layout", "Arrange"],
|
||||
None,
|
||||
"{kind: chart|image|shape, id, toFront?: bool}",
|
||||
"{kind: chart|image|shape|slicer|equation, id, toFront?: bool}",
|
||||
has_doc,
|
||||
|s, p| arrange(s, p, true)
|
||||
),
|
||||
@@ -106,7 +106,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Send Backward",
|
||||
["Page Layout", "Arrange"],
|
||||
None,
|
||||
"{kind: chart|image|shape, id, toBack?: bool}",
|
||||
"{kind: chart|image|shape|slicer|equation, id, toBack?: bool}",
|
||||
has_doc,
|
||||
|s, p| arrange(s, p, false)
|
||||
),
|
||||
@@ -116,7 +116,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Align",
|
||||
["Page Layout", "Arrange"],
|
||||
None,
|
||||
"{kind: chart|image|shape, ids: [..], align: left|center|right|top|middle|bottom}",
|
||||
"{kind: chart|image|shape|slicer|equation, ids: [..], align: left|center|right|top|middle|bottom}",
|
||||
has_doc,
|
||||
align
|
||||
),
|
||||
|
||||
@@ -51,6 +51,7 @@ pub(super) fn toggle_checkbox(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
if let Some(cell) = sh.cells.get_mut(c)
|
||||
&& cell.formula.is_none()
|
||||
&& cell.py.is_none()
|
||||
{
|
||||
// An empty checkbox cell counts as FALSE.
|
||||
let next = match cell.value {
|
||||
|
||||
@@ -43,6 +43,7 @@ pub(super) fn add_element(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
"dataLabels" => c.data_labels = on,
|
||||
"dataTable" => crate::cmd::insert::update_data_table(c, p, Some(on))?,
|
||||
"gridlines" => c.gridlines = on,
|
||||
"axisTitles" => {
|
||||
c.x_title = on.then(|| c.x_title.clone().unwrap_or_else(|| "Axis Title".into()));
|
||||
|
||||
@@ -1,8 +1,29 @@
|
||||
use gridcraft_model::ObjKind;
|
||||
|
||||
use super::chart::format_series;
|
||||
use super::{
|
||||
Anchor, CellRef, Color, EngineError, Json, Result, Session, Shape, Sheet, anchor_mode_name, bad, bool_param, edit, json, str_param, u32_param,
|
||||
Anchor, CellRef, Color, EngineError, Json, Result, Session, Shape, ShapeKind, Sheet, anchor_mode_name, bad, bool_param, edit, json, str_param,
|
||||
u32_param,
|
||||
};
|
||||
|
||||
/// The kind of object a `kind` parameter names: `shape` when the parameter is absent; one that
|
||||
/// is present but not one of the names (`null`, a number, an unknown word) is an error, as for
|
||||
/// the `object.*` commands, where the kind is required.
|
||||
fn kind_param(p: &Json, cmd: &str) -> Result<ObjKind> {
|
||||
let err = || bad(cmd, "kind must be chart, image, shape, slicer or equation");
|
||||
match p.get("kind") {
|
||||
None => Ok(ObjKind::Shape),
|
||||
Some(Json::String(k)) => ObjKind::parse(k).ok_or_else(err),
|
||||
Some(_) => Err(err()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `kind` names the shape `sh`: an equation is a shape too, and a plain shape is not an
|
||||
/// equation, as `Sheet::object_anchor` has it for the `object.*` commands.
|
||||
fn shape_is(kind: ObjKind, sh: &Shape) -> bool {
|
||||
kind != ObjKind::Equation || sh.kind == ShapeKind::Equation
|
||||
}
|
||||
|
||||
pub(super) fn shape_fill(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if str_param(p, "kind") == Some("shape") {
|
||||
let color = str_param(p, "color").and_then(Color::from_hex).unwrap_or_default();
|
||||
@@ -33,7 +54,7 @@ fn with_shape(s: &mut Session, p: &Json, f: impl FnOnce(&mut Shape)) -> Result<J
|
||||
|
||||
pub(super) fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result<Json> {
|
||||
let id = u32_param(p, "id").ok_or_else(|| bad("arrange", "missing `id`"))?;
|
||||
let kind = str_param(p, "kind").unwrap_or("shape").to_string();
|
||||
let kind = kind_param(p, "arrange")?;
|
||||
let extreme = bool_param(p, "toFront").or(bool_param(p, "toBack")).unwrap_or(false);
|
||||
let si = s.doc()?.wb.active_sheet();
|
||||
fn reorder<T>(v: &mut Vec<T>, i: usize, forward: bool, extreme: bool) {
|
||||
@@ -51,23 +72,17 @@ pub(super) fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result<Json>
|
||||
}
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
match kind.as_str() {
|
||||
"chart" => {
|
||||
if let Some(i) = sh.charts.iter().position(|c| c.id == id) {
|
||||
reorder(&mut sh.charts, i, forward, extreme);
|
||||
}
|
||||
// An id that is not an object of this kind is an error, so a stale id, or a shape's id
|
||||
// given as an equation, never reorders something else or succeeds silently.
|
||||
let found = match kind {
|
||||
ObjKind::Chart => sh.charts.iter().position(|c| c.id == id).map(|i| reorder(&mut sh.charts, i, forward, extreme)),
|
||||
ObjKind::Image => sh.images.iter().position(|c| c.id == id).map(|i| reorder(&mut sh.images, i, forward, extreme)),
|
||||
ObjKind::Slicer => sh.slicers.iter().position(|c| c.id == id).map(|i| reorder(&mut sh.slicers, i, forward, extreme)),
|
||||
ObjKind::Shape | ObjKind::Equation => {
|
||||
sh.shapes.iter().position(|c| c.id == id && shape_is(kind, c)).map(|i| reorder(&mut sh.shapes, i, forward, extreme))
|
||||
}
|
||||
"image" => {
|
||||
if let Some(i) = sh.images.iter().position(|c| c.id == id) {
|
||||
reorder(&mut sh.images, i, forward, extreme);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(i) = sh.shapes.iter().position(|c| c.id == id) {
|
||||
reorder(&mut sh.shapes, i, forward, extreme);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
found.ok_or_else(|| bad("arrange", "no such object"))?;
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
@@ -77,7 +92,8 @@ pub(super) fn selection_pane(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
let mut v = Vec::new();
|
||||
for c in &sh.shapes {
|
||||
v.push(json!({"kind": "shape", "id": c.id, "name": format!("{:?} {}", c.kind, c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
|
||||
let kind = if c.kind == ShapeKind::Equation { "equation" } else { "shape" };
|
||||
v.push(json!({"kind": kind, "id": c.id, "name": format!("{:?} {}", c.kind, c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
|
||||
}
|
||||
for c in &sh.images {
|
||||
v.push(json!({"kind": "image", "id": c.id, "name": format!("Picture {}", c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
|
||||
@@ -85,33 +101,51 @@ pub(super) fn selection_pane(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
for c in &sh.charts {
|
||||
v.push(json!({"kind": "chart", "id": c.id, "name": format!("Chart {}", c.id), "title": c.title, "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
|
||||
}
|
||||
// Slicers paint above the other objects.
|
||||
for c in &sh.slicers {
|
||||
let name = if c.caption.is_empty() { &c.column } else { &c.caption };
|
||||
v.push(json!({"kind": "slicer", "id": c.id, "name": name, "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
|
||||
}
|
||||
Ok(Json::Array(v))
|
||||
}
|
||||
|
||||
fn anchors_of<'a>(sh: &'a mut Sheet, kind: &str) -> Vec<(u32, &'a mut Anchor)> {
|
||||
/// The anchors of every object of `kind` on the sheet; under `equation`, only the equations.
|
||||
fn anchors_of(sh: &mut Sheet, kind: ObjKind) -> Vec<(u32, &mut Anchor)> {
|
||||
match kind {
|
||||
"chart" => sh.charts.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
|
||||
"image" => sh.images.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
|
||||
_ => sh.shapes.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
|
||||
ObjKind::Chart => sh.charts.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
|
||||
ObjKind::Image => sh.images.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
|
||||
ObjKind::Slicer => sh.slicers.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
|
||||
ObjKind::Shape | ObjKind::Equation => sh.shapes.iter_mut().filter(|c| shape_is(kind, c)).map(|c| (c.id, &mut c.anchor)).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn align(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let kind = str_param(p, "kind").unwrap_or("shape").to_string();
|
||||
let ids: Vec<u32> =
|
||||
p.get("ids").and_then(Json::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(|x| x as u32)).collect()).unwrap_or_default();
|
||||
let kind = kind_param(p, "arrange.align")?;
|
||||
// `ids` may be absent (nothing to align) but, present, must be an array whose every entry is
|
||||
// an object id naming an object of the kind: a stale or mismatched id, or a value of another
|
||||
// shape, is an error, as for `arrange` and the `object.*` commands, not something silently
|
||||
// left out.
|
||||
let bad_ids = || bad("arrange.align", "ids must be an array of object ids");
|
||||
let ids: Vec<u32> = match p.get("ids") {
|
||||
None => Vec::new(),
|
||||
Some(Json::Array(a)) => a.iter().map(|v| v.as_u64().and_then(|x| u32::try_from(x).ok()).ok_or_else(bad_ids)).collect::<Result<_>>()?,
|
||||
Some(_) => return Err(bad_ids()),
|
||||
};
|
||||
let how = str_param(p, "align").unwrap_or("left").to_string();
|
||||
let si = s.doc()?.wb.active_sheet();
|
||||
edit(s, |cx| {
|
||||
let sheet = cx.sheet_mut(si)?;
|
||||
let geo: Vec<(u32, f64, f64, f32, f32)> = {
|
||||
let shc = sheet.clone();
|
||||
anchors_of(sheet, &kind)
|
||||
anchors_of(sheet, kind)
|
||||
.into_iter()
|
||||
.filter(|(id, _)| ids.contains(id))
|
||||
.map(|(id, a)| (id, shc.col_left(a.cell.col) + a.dx as f64, shc.row_top(a.cell.row) + a.dy as f64, a.width, a.height))
|
||||
.collect()
|
||||
};
|
||||
if let Some(missing) = ids.iter().find(|id| !geo.iter().any(|g| g.0 == **id)) {
|
||||
return Err(bad("arrange.align", format!("no such object: {missing}")));
|
||||
}
|
||||
if geo.len() < 2 {
|
||||
return Ok(Json::Null);
|
||||
}
|
||||
@@ -120,7 +154,7 @@ pub(super) fn align(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let top = geo.iter().map(|g| g.2).fold(f64::INFINITY, f64::min);
|
||||
let bottom = geo.iter().map(|g| g.2 + g.4 as f64).fold(f64::NEG_INFINITY, f64::max);
|
||||
let shc = sheet.clone();
|
||||
for (id, a) in anchors_of(sheet, &kind) {
|
||||
for (id, a) in anchors_of(sheet, kind) {
|
||||
let Some(g) = geo.iter().find(|g| g.0 == id) else { continue };
|
||||
let (mut x, mut y) = (g.1, g.2);
|
||||
match how.as_str() {
|
||||
|
||||
@@ -138,7 +138,7 @@ pub(super) fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let opened_dialog = s.ui_requests.get(before..).is_some_and(|new| new.iter().any(|r| matches!(r, crate::UiRequest::Dialog(..))));
|
||||
let changed = wb_before.as_ref().is_some_and(|(uid, wb)| s.docs.iter().any(|d| d.uid == *uid && !std::sync::Arc::ptr_eq(&d.wb, wb)));
|
||||
if !opened_dialog || changed {
|
||||
ran_steps.push((id.to_string(), crate::record::with_toggle_state(st["params"].clone(), &result)));
|
||||
ran_steps.push((id.to_string(), crate::record::with_toggle_state(id, st["params"].clone(), &result)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,7 +315,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let vstyle = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.num_fmt = vfmt.clone());
|
||||
let mut sheet = Sheet::new(sheet_name.clone());
|
||||
let put = |sheet: &mut Sheet, row: u32, col: u32, v: Value, style: StyleId| {
|
||||
sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style });
|
||||
sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style, py: None });
|
||||
};
|
||||
let formula = |sheet: &mut Sheet, row: u32, col: u32, text: String, style: StyleId| {
|
||||
let mut cell = Cell::formula(Formula::new(&text));
|
||||
@@ -424,6 +424,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
series,
|
||||
legend: LegendPos::Bottom,
|
||||
data_labels: false,
|
||||
data_table: None,
|
||||
gridlines: true,
|
||||
style: 1,
|
||||
x_title: None,
|
||||
|
||||
@@ -90,8 +90,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
format_cells
|
||||
),
|
||||
// Alignment
|
||||
cmd!("home.alignLeft", "Align Left", ["Home", "Alignment"], Some("Cmd+L"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Left)),
|
||||
cmd!("home.alignCenter", "Center", ["Home", "Alignment"], Some("Cmd+E"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Center)),
|
||||
CommandSpec {
|
||||
shortcut_mac: Some("Cmd+L"),
|
||||
..cmd!("home.alignLeft", "Align Left", ["Home", "Alignment"], Some("Cmd+L"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Left))
|
||||
},
|
||||
CommandSpec {
|
||||
shortcut_mac: Some("Cmd+E"),
|
||||
..cmd!("home.alignCenter", "Center", ["Home", "Alignment"], Some("Cmd+E"), "{range?}", has_doc, |s, p| halign(s, p, HAlign::Center))
|
||||
},
|
||||
cmd!("home.alignRight", "Align Right", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| halign(s, p, HAlign::Right)),
|
||||
cmd!("home.justify", "Justify", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| halign(s, p, HAlign::Justify)),
|
||||
cmd!("home.alignTop", "Top Align", ["Home", "Alignment"], None, "{range?}", has_doc, |s, p| valign(s, p, VAlign::Top)),
|
||||
@@ -650,8 +656,7 @@ fn merge(s: &mut Session, p: &Json, how: &str) -> Result<Json> {
|
||||
let others: Vec<CellRef> = sh.cells.iter_range(m).map(|(c, _)| c).filter(|c| *c != m.start).collect();
|
||||
for c in others {
|
||||
if let Some(mut cell) = sh.cells.get(c).cloned() {
|
||||
cell.value = gridcraft_core::Value::Empty;
|
||||
cell.formula = None;
|
||||
cell.clear_content();
|
||||
sh.set_cell(c, cell);
|
||||
}
|
||||
touched.push((sheet, c));
|
||||
@@ -668,8 +673,7 @@ fn merge(s: &mut Session, p: &Json, how: &str) -> Result<Json> {
|
||||
let others: Vec<CellRef> = sh.cells.iter_range(*r).map(|(c, _)| c).filter(|c| *c != r.start).collect();
|
||||
for c in others {
|
||||
if let Some(mut cell) = sh.cells.get(c).cloned() {
|
||||
cell.value = gridcraft_core::Value::Empty;
|
||||
cell.formula = None;
|
||||
cell.clear_content();
|
||||
sh.set_cell(c, cell);
|
||||
}
|
||||
touched.push((sheet, c));
|
||||
@@ -686,7 +690,10 @@ fn merge(s: &mut Session, p: &Json, how: &str) -> Result<Json> {
|
||||
let blocked = sh
|
||||
.cells
|
||||
.iter_range(reach)
|
||||
.filter(|(_, x)| x.formula.is_some() && x.value == gridcraft_core::Value::Error(gridcraft_core::CellError::Spill));
|
||||
// A Python cell's result is blocked and unblocked like a formula's.
|
||||
.filter(|(_, x)| {
|
||||
(x.formula.is_some() || x.py.is_some()) && x.value == gridcraft_core::Value::Error(gridcraft_core::CellError::Spill)
|
||||
});
|
||||
touched.extend(blocked.map(|(c, _)| (sheet, c)));
|
||||
}
|
||||
cx.changed.extend(touched);
|
||||
|
||||
@@ -69,6 +69,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!(query "formulas.tracePrecedents", "Trace Precedents", ["Formulas", "Formula Auditing"], None, "{cell?} → ranges", has_doc, trace_precedents),
|
||||
cmd!(query "formulas.traceDependents", "Trace Dependents", ["Formulas", "Formula Auditing"], None, "{cell?} → cells", has_doc, trace_dependents),
|
||||
cmd!(
|
||||
query "formulas.traceError",
|
||||
"Trace Error",
|
||||
["Formulas", "Formula Auditing"],
|
||||
None,
|
||||
"{cell?} → {sheet, cell, error, source: {sheet, cell}, arrows: [{from: {sheet, range}, to: {sheet, cell}, kind: \"error\"|\"precedent\"}], truncated}: from the cell, follows the first reference that shows the same error (in a range, its first cell showing it) back to the cell the error starts in, `source`. `error` arrows link the cells on the way; `precedent` arrows lead from the source's own references into it. A cell without an error gives `error` and `source` null and no arrows. A chain longer than 10,000 links stops there with `truncated` true and `source` null.",
|
||||
has_doc,
|
||||
trace_error
|
||||
),
|
||||
cmd!(noundo "formulas.selectPrecedents", "Select Precedents", ["Formulas", "Formula Auditing"], Some("Ctrl+["), "{cell?, all?: true = every level (Ctrl+Shift+[)} → selects the precedent cells", has_doc, select_precedents),
|
||||
cmd!(noundo "formulas.selectDependents", "Select Dependents", ["Formulas", "Formula Auditing"], Some("Ctrl+]"), "{cell?, all?: true = every level (Ctrl+Shift+])}", has_doc, select_dependents),
|
||||
cmd!(noundo "formulas.removeArrows", "Remove Arrows", ["Formulas", "Formula Auditing"], None, "{}", has_doc, |_, _| ok()),
|
||||
@@ -626,7 +635,7 @@ fn paste_name(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
let style = sh.style_id(c);
|
||||
// The second column holds the refers-to text as a text value, with no formula.
|
||||
sh.set_cell(c, Cell { value: Value::text(text.as_str()), formula: None, style });
|
||||
sh.set_cell(c, Cell { value: Value::text(text.as_str()), formula: None, style, py: None });
|
||||
cx.touch(sheet, c);
|
||||
}
|
||||
}
|
||||
@@ -653,6 +662,79 @@ fn trace_dependents(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Ok(Json::Array(v))
|
||||
}
|
||||
|
||||
/// Trace Error goes back at most this many links; a longer chain stops there.
|
||||
const TRACE_ERROR_LINKS: usize = 10_000;
|
||||
|
||||
/// Trace Error: from the cell, the first reference in its formula that shows the same error
|
||||
/// (in a range, the range's first cell showing it, row by row), then that cell's, and so on to the
|
||||
/// cell the error starts in: one none of whose references shows it, or one without references.
|
||||
/// Arrows link the cells on the way, and lead from the source's own references into it.
|
||||
fn trace_error(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
use std::collections::HashSet;
|
||||
let d = s.doc()?;
|
||||
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
|
||||
let sheet = d.wb.active_sheet();
|
||||
let name = |si: usize| d.wb.sheet(si).map(|sh| sh.name.clone());
|
||||
let cell = |(si, c): (usize, CellRef)| json!({"sheet": name(si), "cell": c.a1()});
|
||||
let Some(error) = d.wb.sheet(sheet).and_then(|sh| sh.value(at).as_error()) else {
|
||||
return Ok(json!({"sheet": name(sheet), "cell": at.a1(), "error": null, "source": null, "arrows": [], "truncated": false}));
|
||||
};
|
||||
// A value spilled by a dynamic array comes from its anchor's formula.
|
||||
let precedents = |(si, c): (usize, CellRef)| {
|
||||
let anchor = d.wb.sheet(si).filter(|sh| sh.cell(c).is_none_or(|x| x.formula.is_none())).and_then(|sh| sh.spill_anchor(c));
|
||||
d.calc.graph.precedents_of((si, anchor.unwrap_or(c)))
|
||||
};
|
||||
// The walk keeps to cells it hasn't been to, so a circular reference ends it.
|
||||
let mut chain = vec![(sheet, at)];
|
||||
let mut seen: HashSet<(usize, CellRef)> = HashSet::from([(sheet, at)]);
|
||||
let mut truncated = false;
|
||||
let mut last = (sheet, at);
|
||||
while let Some(next) = precedents(last).iter().find_map(|a| first_showing(&d.wb, a, error)) {
|
||||
if !seen.insert(next) {
|
||||
break;
|
||||
}
|
||||
if chain.len() > TRACE_ERROR_LINKS {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
chain.push(next);
|
||||
last = next;
|
||||
}
|
||||
let mut arrows = Vec::new();
|
||||
// A chain cut short hasn't reached its source.
|
||||
if !truncated {
|
||||
let mut drawn = HashSet::new();
|
||||
for a in precedents(last) {
|
||||
if drawn.insert(a) {
|
||||
arrows.push(json!({"from": {"sheet": name(a.sheet), "range": a.range.a1()}, "to": cell(last), "kind": "precedent"}));
|
||||
}
|
||||
}
|
||||
}
|
||||
// From the source outwards, as the error travels.
|
||||
for pair in chain.windows(2).rev() {
|
||||
if let [to, from] = pair {
|
||||
arrows.push(json!({"from": {"sheet": name(from.0), "range": from.1.a1()}, "to": cell(*to), "kind": "error"}));
|
||||
}
|
||||
}
|
||||
let source = if truncated { Json::Null } else { cell(last) };
|
||||
Ok(json!({"sheet": name(sheet), "cell": at.a1(), "error": error.as_str(), "source": source, "arrows": arrows, "truncated": truncated}))
|
||||
}
|
||||
|
||||
/// The first cell of an area, row by row, that shows `error`: a stored cell or a value spilled
|
||||
/// into the area by a dynamic array.
|
||||
fn first_showing(wb: &Workbook, a: &gridcraft_calc::Area, error: CellError) -> Option<(usize, CellRef)> {
|
||||
let sh = wb.sheet(a.sheet)?;
|
||||
let holds = |c: &Cell| !c.value.is_empty() || c.formula.is_some();
|
||||
let stored = sh.cells.iter_range(a.range).find(|(_, c)| holds(c) && c.value.as_error() == Some(error)).map(|(at, _)| at);
|
||||
let spilled = sh
|
||||
.spill
|
||||
.range(a.range.start..)
|
||||
.take_while(|(at, _)| **at <= a.range.end)
|
||||
.find(|(at, v)| a.range.contains(**at) && v.as_error() == Some(error) && !sh.cell(**at).is_some_and(holds))
|
||||
.map(|(at, _)| *at);
|
||||
stored.into_iter().chain(spilled).min().map(|c| (a.sheet, c))
|
||||
}
|
||||
|
||||
/// Excel's Ctrl+[ / Ctrl+]: select the precedent (or dependent) cells. When they all live on
|
||||
/// another sheet, jump there. `all` walks every level (Ctrl+Shift+[ / Ctrl+Shift+]).
|
||||
fn select_trace_cells(s: &mut Session, p: &Json, precedents: bool) -> Result<Json> {
|
||||
|
||||
+200
-60
@@ -1,12 +1,12 @@
|
||||
//! Insert tab: tables, charts, sparklines, pictures, shapes, hyperlinks, comments and notes,
|
||||
//! conditional formatting (Home › Styles) and symbols.
|
||||
|
||||
use gridcraft_core::{CellRef, MAX_ROWS, RangeRef};
|
||||
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef};
|
||||
use gridcraft_formula::adjust::{Axis, Edit};
|
||||
use gridcraft_model::{
|
||||
Anchor, BorderLine, BorderStyle, Borders, Cell, CellPicture, CfOperator, CfRule, CfValueKind, Chart, ChartKind, Color, Comment, CondFormat, Fill,
|
||||
Formula, Hyperlink, Image, LegendPos, Series, Shape, ShapeKind, Sheet, Sparkline, SparklineKind, Style, Table, TableColumn, TotalsFn, Underline,
|
||||
Workbook,
|
||||
Formula, Hyperlink, Image, LegendPos, ObjKind, ObjRef, Series, Shape, ShapeKind, Sheet, Sparkline, SparklineKind, Style, Table, TableColumn,
|
||||
TotalsFn, Underline, Workbook,
|
||||
};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
@@ -99,7 +99,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Chart Properties",
|
||||
["Chart Design"],
|
||||
None,
|
||||
"{chart?: id, type?, subtype?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
|
||||
"{chart?: id, type?, subtype?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, dataTable?: bool, dataTableKeys?: bool, dataTableHorizontalBorder?: bool, dataTableVerticalBorder?: bool, dataTableOutline?: bool (data tables: column/bar/line/area and compatible combo charts), quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
|
||||
has_doc,
|
||||
chart_set
|
||||
),
|
||||
@@ -146,14 +146,22 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
&with(p, "kind", json!("textBox"))
|
||||
)),
|
||||
cmd!("shape.setText", "Edit Text Box", [], None, "{id: textBox id, text: string}", has_doc, shape_set_text),
|
||||
cmd!("object.delete", "Delete Object", [], None, "{kind: chart|image|shape, id}", has_doc, delete_object),
|
||||
cmd!("object.move", "Move Object", [], None, "{kind: chart|image|shape, id, at?: \"C3\", dx?, dy?, width?, height?}", has_doc, move_object),
|
||||
cmd!("object.delete", "Delete Object", [], None, "{kind: chart|image|shape|slicer|equation, id}", has_doc, delete_object),
|
||||
cmd!(
|
||||
"object.move",
|
||||
"Move Object",
|
||||
[],
|
||||
None,
|
||||
"{kind: chart|image|shape|slicer|equation, id, at?: \"C3\", dx?, dy?, width?, height? (each at most 10,000 points, or the object's current size when a file made it larger)}",
|
||||
has_doc,
|
||||
move_object
|
||||
),
|
||||
cmd!(
|
||||
"object.setAnchorMode",
|
||||
"Object Properties",
|
||||
[],
|
||||
None,
|
||||
"{kind: chart|image|shape, id, mode: moveAndSize|moveOnly|absolute}",
|
||||
"{kind: chart|image|shape|slicer|equation, id, mode: moveAndSize|moveOnly|absolute}",
|
||||
has_doc,
|
||||
set_anchor_mode
|
||||
),
|
||||
@@ -234,14 +242,30 @@ fn block(s: &Session, p: &Json) -> Result<RangeRef> {
|
||||
}
|
||||
|
||||
fn chart_areas(s: &Session, p: &Json) -> Result<Vec<RangeRef>> {
|
||||
if p.get("range").is_some() {
|
||||
return target_ranges(s, p);
|
||||
let mut areas = if p.get("range").is_some() {
|
||||
target_ranges(s, p)?
|
||||
} else if s.doc()?.selection.ranges.len() > 1 {
|
||||
s.doc()?.selection.ranges.clone()
|
||||
} else {
|
||||
vec![block(s, p)?]
|
||||
};
|
||||
// Whole columns/rows span a million cells: clip their far edges to the cells in use, so only
|
||||
// a block that really is too large gets refused. Bounded areas keep their extent as given.
|
||||
let sheet = target_sheet(s, p)?;
|
||||
if let Some(used) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
|
||||
// Areas that start outside the used range keep their extent as given.
|
||||
for r in &mut areas {
|
||||
let whole_cols = r.start.row == 0 && r.end.row >= MAX_ROWS - 1;
|
||||
let whole_rows = r.start.col == 0 && r.end.col >= MAX_COLS - 1;
|
||||
if whole_cols && r.start.row <= used.end.row {
|
||||
r.end.row = r.end.row.min(used.end.row);
|
||||
}
|
||||
if whole_rows && r.start.col <= used.end.col {
|
||||
r.end.col = r.end.col.min(used.end.col);
|
||||
}
|
||||
}
|
||||
}
|
||||
let d = s.doc()?;
|
||||
if d.selection.ranges.len() > 1 {
|
||||
return Ok(d.selection.ranges.clone());
|
||||
}
|
||||
Ok(vec![block(s, p)?])
|
||||
Ok(areas)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- tables
|
||||
@@ -292,7 +316,7 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
// The header row pushes the data down one row: never off the end of the sheet.
|
||||
let last = RangeRef::new(CellRef::new(MAX_ROWS - 1, r.start.col), CellRef::new(MAX_ROWS - 1, r.end.col));
|
||||
if sh.cells.iter_range(last).any(|(_, x)| x.formula.is_some() || !x.value.is_empty()) {
|
||||
if sh.cells.iter_range(last).any(|(_, x)| x.has_content()) {
|
||||
return Err(EngineError::Other("To prevent possible loss of data, cells can't be pushed off the end of the worksheet.".into()));
|
||||
}
|
||||
sh.cells.shift_rows_in_cols(r.start.col, r.end.col, r.start.row, 1);
|
||||
@@ -301,7 +325,7 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let e = Edit::InsertCells { axis: Axis::Rows, range: RangeRef::new(r.start, CellRef::new(r.start.row, r.end.col)) };
|
||||
shift_cell_features(sh, &e);
|
||||
let name = sh.name.clone();
|
||||
rewrite(cx, &name, &e);
|
||||
rewrite(cx, &name, &e)?;
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
for (i, c) in (r.start.col..=r.end.col).enumerate() {
|
||||
sh.set_value(CellRef::new(r.start.row, c), gridcraft_core::Value::text(format!("Column{}", i + 1)));
|
||||
@@ -452,7 +476,10 @@ fn write_totals(sh: &mut Sheet, ti: usize) {
|
||||
let c = CellRef::new(row, t.range.start.col + i as u32);
|
||||
if let Some(code) = col.totals.subtotal_code() {
|
||||
let f = format!("=SUBTOTAL({code},{}[{}])", t.name, col.name);
|
||||
sh.set_cell(c, Cell { formula: Some(std::sync::Arc::new(Formula::new(&f))), value: gridcraft_core::Value::Empty, style: sh.style_id(c) });
|
||||
sh.set_cell(
|
||||
c,
|
||||
Cell { formula: Some(std::sync::Arc::new(Formula::new(&f))), value: gridcraft_core::Value::Empty, style: sh.style_id(c), py: None },
|
||||
);
|
||||
} else if let Some(l) = &col.totals_label {
|
||||
sh.set_value(c, gridcraft_core::Value::text(l.as_str()));
|
||||
} else {
|
||||
@@ -820,6 +847,7 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
|
||||
series,
|
||||
legend: if matches!(kind, ChartKind::Pie | ChartKind::Doughnut) { LegendPos::Right } else { LegendPos::Bottom },
|
||||
data_labels: false,
|
||||
data_table: None,
|
||||
gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel),
|
||||
style: 1,
|
||||
x_title: None,
|
||||
@@ -867,7 +895,11 @@ fn chart_set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let Some(c) = sh.charts.get_mut(ci) else { return Ok(Json::Null) };
|
||||
if let Some(t) = str_param(p, "type") {
|
||||
c.kind = chart_kind(t, str_param(p, "subtype").unwrap_or(""));
|
||||
if !c.supports_data_table() {
|
||||
c.data_table = None;
|
||||
}
|
||||
}
|
||||
update_data_table(c, p, bool_param(p, "dataTable"))?;
|
||||
if let Some(method) = quartiles {
|
||||
for series in &mut c.series {
|
||||
series.quartiles = method;
|
||||
@@ -904,10 +936,10 @@ fn chart_set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
c.anchor.cell = at;
|
||||
}
|
||||
if let Some(w) = f64_param(p, "width") {
|
||||
c.anchor.width = w.clamp(20.0, 10000.0) as f32;
|
||||
c.anchor.width = w.clamp(20.0, size_cap(c.anchor.width)) as f32;
|
||||
}
|
||||
if let Some(h) = f64_param(p, "height") {
|
||||
c.anchor.height = h.clamp(20.0, 10000.0) as f32;
|
||||
c.anchor.height = h.clamp(20.0, size_cap(c.anchor.height)) as f32;
|
||||
}
|
||||
if let Some(v) = f64_param(p, "dx") {
|
||||
c.anchor.dx = v as f32;
|
||||
@@ -919,6 +951,32 @@ fn chart_set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Shared by Chart Properties and Add Chart Element; no separate UI state.
|
||||
pub(super) fn update_data_table(c: &mut Chart, p: &Json, on: Option<bool>) -> Result<()> {
|
||||
if p.get("dataTable").is_some_and(|v| !v.is_boolean()) {
|
||||
return Err(bad("chart", "`dataTable` must be a boolean"));
|
||||
}
|
||||
if on == Some(false) {
|
||||
c.data_table = None;
|
||||
return Ok(());
|
||||
}
|
||||
let fields = ["dataTableKeys", "dataTableHorizontalBorder", "dataTableVerticalBorder", "dataTableOutline"];
|
||||
if on == Some(true) || fields.iter().any(|k| p.get(k).is_some()) {
|
||||
if !c.supports_data_table() {
|
||||
return Err(bad("chart", "data tables need a column, bar, line, area or compatible combo chart"));
|
||||
}
|
||||
let table = c.data_table.get_or_insert_with(Default::default);
|
||||
for (key, value) in
|
||||
fields.into_iter().zip([&mut table.show_keys, &mut table.horizontal_border, &mut table.vertical_border, &mut table.outline])
|
||||
{
|
||||
if let Some(v) = p.get(key) {
|
||||
*value = v.as_bool().ok_or_else(|| bad("chart", format!("`{key}` must be a boolean")))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn chart_switch(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (si, ci) = find_chart(s, p)?;
|
||||
edit(s, |cx| {
|
||||
@@ -1115,6 +1173,7 @@ fn insert_shape(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Color::Theme(4, -250)
|
||||
});
|
||||
let shape = Shape {
|
||||
math: Default::default(),
|
||||
id,
|
||||
kind,
|
||||
anchor: Anchor {
|
||||
@@ -1168,51 +1227,62 @@ fn shape_set_text(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The floating object a `{kind, id}` command names on the active sheet. Unknown kinds and
|
||||
/// objects are errors, so a stale id never succeeds silently.
|
||||
fn object_param(s: &Session, p: &Json, cmd: &str) -> Result<(usize, ObjRef)> {
|
||||
let id = u32_param(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?;
|
||||
let kind = str_param(p, "kind").and_then(ObjKind::parse).ok_or_else(|| bad(cmd, "kind must be chart, image, shape, slicer or equation"))?;
|
||||
let d = s.doc()?;
|
||||
let sheet = d.wb.active_sheet();
|
||||
let o = ObjRef::new(kind, id);
|
||||
if !d.wb.sheet(sheet).is_some_and(|sh| sh.has_object(o)) {
|
||||
return Err(bad(cmd, "no such object"));
|
||||
}
|
||||
Ok((sheet, o))
|
||||
}
|
||||
|
||||
fn delete_object(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let id = u32_param(p, "id").ok_or_else(|| bad("object.delete", "missing `id`"))?;
|
||||
let kind = str_param(p, "kind").unwrap_or("").to_string();
|
||||
let sheet = s.doc()?.wb.active_sheet();
|
||||
let (sheet, o) = object_param(s, p, "object.delete")?;
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
match kind.as_str() {
|
||||
"chart" => sh.charts.retain(|c| c.id != id),
|
||||
"image" => sh.images.retain(|c| c.id != id),
|
||||
"slicer" => sh.slicers.retain(|c| c.id != id),
|
||||
_ => sh.shapes.retain(|c| c.id != id),
|
||||
}
|
||||
Ok(Json::Null)
|
||||
cx.sheet_mut(sheet)?.remove_object(o);
|
||||
Ok(json!({"deleted": o}))
|
||||
})
|
||||
}
|
||||
|
||||
/// Largest offset (points) `object.move` accepts from the anchor cell.
|
||||
const MAX_OBJECT_OFFSET: f64 = 100_000.0;
|
||||
|
||||
/// Largest width or height (points) `object.move` and `chart.set` give an object that is not
|
||||
/// already larger: see [`size_cap`].
|
||||
const MAX_OBJECT_SIZE: f64 = 10_000.0;
|
||||
|
||||
/// The most a command may make a dimension that is `current` now: [`MAX_OBJECT_SIZE`], or the
|
||||
/// current size when a file made the object larger (the XLSX reader accepts anchors far past the
|
||||
/// limit). Such an object keeps its size when it is moved and shrinks by what a resize asks, but
|
||||
/// it never grows further, so hostile input is still capped. Never NaN: `f64::max` drops one.
|
||||
fn size_cap(current: f32) -> f64 {
|
||||
MAX_OBJECT_SIZE.max(f64::from(current))
|
||||
}
|
||||
|
||||
fn move_object(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let id = u32_param(p, "id").ok_or_else(|| bad("object.move", "missing `id`"))?;
|
||||
let kind = str_param(p, "kind").unwrap_or("").to_string();
|
||||
let sheet = s.doc()?.wb.active_sheet();
|
||||
let (sheet, o) = object_param(s, p, "object.move")?;
|
||||
let at = cell_param(s, p, "at")?;
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
let anchor = match kind.as_str() {
|
||||
"chart" => sh.charts.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
|
||||
"image" => sh.images.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
|
||||
"slicer" => sh.slicers.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
|
||||
"shape" => sh.shapes.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
|
||||
_ => None,
|
||||
};
|
||||
let Some(a) = anchor else { return Err(bad("object.move", "no such object")) };
|
||||
let Some(a) = cx.sheet_mut(sheet)?.object_anchor_mut(o) else { return Err(bad("object.move", "no such object")) };
|
||||
if let Some(at) = at {
|
||||
a.cell = at;
|
||||
}
|
||||
if let Some(v) = f64_param(p, "dx") {
|
||||
a.dx = v as f32;
|
||||
a.dx = v.clamp(-MAX_OBJECT_OFFSET, MAX_OBJECT_OFFSET) as f32;
|
||||
}
|
||||
if let Some(v) = f64_param(p, "dy") {
|
||||
a.dy = v as f32;
|
||||
a.dy = v.clamp(-MAX_OBJECT_OFFSET, MAX_OBJECT_OFFSET) as f32;
|
||||
}
|
||||
if let Some(v) = f64_param(p, "width") {
|
||||
a.width = v.clamp(1.0, 10000.0) as f32;
|
||||
a.width = v.clamp(1.0, size_cap(a.width)) as f32;
|
||||
}
|
||||
if let Some(v) = f64_param(p, "height") {
|
||||
a.height = v.clamp(0.0, 10000.0) as f32;
|
||||
a.height = v.clamp(0.0, size_cap(a.height)) as f32;
|
||||
}
|
||||
Ok(Json::Null)
|
||||
})
|
||||
@@ -1222,21 +1292,10 @@ fn move_object(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
/// cells" / "Don't move or size with cells"), so it follows — or ignores — its rows and columns
|
||||
/// when the sheet is sorted or edited structurally.
|
||||
fn set_anchor_mode(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let id = u32_param(p, "id").ok_or_else(|| bad("object.setAnchorMode", "missing `id`"))?;
|
||||
let kind = str_param(p, "kind").unwrap_or("").to_string();
|
||||
if !matches!(kind.as_str(), "chart" | "image" | "shape") {
|
||||
return Err(bad("object.setAnchorMode", "kind must be chart, image or shape"));
|
||||
}
|
||||
let (sheet, o) = object_param(s, p, "object.setAnchorMode")?;
|
||||
let mode = anchor_mode_param(p).ok_or_else(|| bad("object.setAnchorMode", "mode must be moveAndSize, moveOnly or absolute"))?;
|
||||
let sheet = s.doc()?.wb.active_sheet();
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
let anchor = match kind.as_str() {
|
||||
"chart" => sh.charts.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
|
||||
"image" => sh.images.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
|
||||
_ => sh.shapes.iter_mut().find(|c| c.id == id).map(|c| &mut c.anchor),
|
||||
};
|
||||
let Some(a) = anchor else { return Err(bad("object.setAnchorMode", "no such object")) };
|
||||
let Some(a) = cx.sheet_mut(sheet)?.object_anchor_mut(o) else { return Err(bad("object.setAnchorMode", "no such object")) };
|
||||
a.mode = mode;
|
||||
Ok(json!({"mode": anchor_mode_name(mode)}))
|
||||
})
|
||||
@@ -1267,7 +1326,7 @@ fn insert_link(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
});
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
if let Some(t) = text.clone().or_else(|| sh.value(at).is_empty().then(|| target.clone())) {
|
||||
sh.set_cell(at, Cell { value: gridcraft_core::Value::text(t.as_str()), formula: None, style: link_style });
|
||||
sh.set_cell(at, Cell { value: gridcraft_core::Value::text(t.as_str()), formula: None, style: link_style, py: None });
|
||||
} else {
|
||||
sh.set_style(at, link_style);
|
||||
}
|
||||
@@ -1729,6 +1788,7 @@ fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
|
||||
let color = super::format::color_param(p.get("color")).unwrap_or(Color::Theme(1, 0));
|
||||
let shape = Shape {
|
||||
math: Default::default(),
|
||||
id,
|
||||
kind: ShapeKind::Icon,
|
||||
anchor: Anchor { cell: at, dx: 4.0, dy: 4.0, width: size, height: size, mode: anchor_mode_param(p).unwrap_or_default() },
|
||||
@@ -1748,6 +1808,86 @@ fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn chart_from_whole_columns_uses_the_rows_in_use() {
|
||||
// #3114: selecting two whole columns of data refused the chart as "range too large".
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let values: Vec<Vec<String>> = (1..=5).map(|i| vec![i.to_string(), (i * 10).to_string()]).collect();
|
||||
s.execute("range.setValues", json!({"range": "A1:B5", "values": values})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "A:B"})).unwrap();
|
||||
s.execute("insert.chart", json!({"type": "line"})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let chart = d.wb.active().unwrap().charts.last().unwrap();
|
||||
let values: Vec<&str> = chart.series.iter().map(|x| x.values.as_str()).collect();
|
||||
assert_eq!(values, ["Sheet1!$A$1:$A$5", "Sheet1!$B$1:$B$5"]);
|
||||
// A block that really is too large still is.
|
||||
s.execute("cell.set", json!({"cell": "B600000", "input": "1"})).unwrap();
|
||||
assert!(s.execute("insert.chart", json!({"range": "A:B", "type": "line"})).is_err());
|
||||
// A bounded block keeps its extent, even where it runs past the data.
|
||||
s.execute("insert.chart", json!({"range": "A1:A20", "type": "line"})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let chart = d.wb.active().unwrap().charts.last().unwrap();
|
||||
let values: Vec<&str> = chart.series.iter().map(|x| x.values.as_str()).collect();
|
||||
assert_eq!(values, ["Sheet1!$A$1:$A$20"]);
|
||||
// A bounded block entirely below the data keeps its extent.
|
||||
s.execute("insert.chart", json!({"range": "C700000:C700010", "type": "line"})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let chart = d.wb.active().unwrap().charts.last().unwrap();
|
||||
let values: Vec<&str> = chart.series.iter().map(|x| x.values.as_str()).collect();
|
||||
assert_eq!(values, ["Sheet1!$C$700000:$C$700010"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chart_data_table_commands_undo_and_follow_live_values() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "Sales"], ["Q1", 10], ["Q2", 20]]})).unwrap();
|
||||
s.execute("insert.chart", json!({"range": "A1:B3", "type": "column"})).unwrap();
|
||||
s.execute("chart.addElement", json!({"element": "dataTable", "dataTableKeys": false})).unwrap();
|
||||
let get = |s: &Session| s.active().unwrap().wb.active().unwrap().charts[0].clone();
|
||||
let table = get(&s).data_table.unwrap();
|
||||
assert!(!table.show_keys && table.horizontal_border && table.vertical_border && table.outline);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(get(&s).data_table, None);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(get(&s).data_table, Some(table));
|
||||
s.execute("chart.set", json!({"dataTableKeys": true, "dataTableHorizontalBorder": false})).unwrap();
|
||||
assert!(get(&s).data_table.as_ref().unwrap().show_keys);
|
||||
assert!(!get(&s).data_table.as_ref().unwrap().horizontal_border);
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "=12*3"})).unwrap();
|
||||
let d = s.active().unwrap();
|
||||
assert_eq!(gridcraft_chart::resolve(&d.wb, 0, &get(&s)).series[0].values[0], Some(36.0));
|
||||
s.execute("chart.addElement", json!({"element": "dataTable", "on": false})).unwrap();
|
||||
assert_eq!(get(&s).data_table, None);
|
||||
// Old JSON documents have no table field.
|
||||
let mut json = serde_json::to_value(get(&s)).unwrap();
|
||||
json.as_object_mut().unwrap().remove("data_table");
|
||||
assert_eq!(serde_json::from_value::<Chart>(json).unwrap().data_table, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chart_data_table_refuses_unsupported_types_without_changing_history() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Q", "Sales"], ["Q1", 10], ["Q2", 20]]})).unwrap();
|
||||
s.execute("insert.chart", json!({"range": "A1:B3", "type": "pie"})).unwrap();
|
||||
let before = s.active().unwrap().wb.clone();
|
||||
let history = s.execute("history.list", json!({})).unwrap();
|
||||
for params in [json!({"dataTable": true}), json!({"dataTableKeys": true}), json!({"dataTable": "yes"})] {
|
||||
assert!(s.execute("chart.set", params).is_err());
|
||||
assert_eq!(s.active().unwrap().wb, before);
|
||||
assert_eq!(s.execute("history.list", json!({})).unwrap(), history);
|
||||
}
|
||||
assert!(s.execute("chart.addElement", json!({"element": "dataTable"})).is_err());
|
||||
s.execute("chart.set", json!({"type": "line", "dataTable": true})).unwrap();
|
||||
assert!(s.active().unwrap().wb.active().unwrap().charts[0].data_table.is_some());
|
||||
s.execute("chart.set", json!({"type": "scatter"})).unwrap();
|
||||
assert!(s.active().unwrap().wb.active().unwrap().charts[0].data_table.is_none());
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.active().unwrap().wb.active().unwrap().charts[0].data_table.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manage_rules_delete_then_move() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -260,6 +260,7 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
|
||||
series,
|
||||
legend: if matches!(kind, ChartKind::Pie | ChartKind::Doughnut) { LegendPos::Right } else { LegendPos::Bottom },
|
||||
data_labels: false,
|
||||
data_table: None,
|
||||
gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel),
|
||||
style: 1,
|
||||
x_title: None,
|
||||
|
||||
@@ -333,6 +333,7 @@ pub(super) fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
line: Color::Auto,
|
||||
text: name,
|
||||
format: Default::default(),
|
||||
math: Default::default(),
|
||||
};
|
||||
let sheet = d.wb.active_sheet();
|
||||
edit(s, |cx| {
|
||||
|
||||
@@ -153,6 +153,7 @@ pub(super) fn insert_shape(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
line,
|
||||
text: str_param(p, "text").unwrap_or("").into(),
|
||||
format: Default::default(),
|
||||
math: Default::default(),
|
||||
};
|
||||
let sheet = d.wb.active_sheet();
|
||||
edit(s, |cx| {
|
||||
|
||||
@@ -60,7 +60,7 @@ pub(super) fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let e = Edit::InsertCells { axis: Axis::Rows, range: RangeRef::new(r.start, CellRef::new(r.start.row, r.end.col)) };
|
||||
shift_cell_features(sh, &e);
|
||||
let name = sh.name.clone();
|
||||
rewrite(cx, &name, &e);
|
||||
rewrite(cx, &name, &e)?;
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
for (i, c) in (r.start.col..=r.end.col).enumerate() {
|
||||
sh.set_value(CellRef::new(r.start.row, c), gridcraft_core::Value::text(format!("Column{}", i + 1)));
|
||||
|
||||
@@ -9,7 +9,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "document.inspect", "Inspect Workbook", [], None, "{} → sheets, selection, names, tables, charts, dirty, trust (local|fromFile), history", has_doc, inspect),
|
||||
cmd!(query "sheet.read", "Read Range", [], None, "{range?: \"A1:D10\" (default: used range), sheet?, formulas?: bool, formatted?: bool} → rows of values", has_doc, read),
|
||||
cmd!(query "cell.get", "Get Cell", [], None, "{cell?, sheet?} → value, formula, display text, style, comment, link", has_doc, cell_get),
|
||||
cmd!(query "cell.get", "Get Cell", [], None, "{cell?, sheet?} → value, formula, display text, style, comment, link, python {code, output, typeName, preview, stale}", has_doc, cell_get),
|
||||
cmd!(query "selection.stats", "Selection Statistics", [], None, "{} → count, sum, average, min, max", has_doc, sel_stats),
|
||||
cmd!(query "history.list", "Undo History", [], None, "{} → undo/redo labels, grouping (an undo group is open)", has_doc, history),
|
||||
cmd!(query "app.commands", "List Commands", [], None, "{search?} → ids, labels, params", always, list_commands),
|
||||
@@ -46,7 +46,7 @@ fn inspect(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
"freeze": sh.freeze,
|
||||
"merges": sh.merges.iter().map(|m| m.a1()).collect::<Vec<_>>(),
|
||||
"tables": sh.tables.iter().map(|t| json!({"name": t.name, "range": t.range.a1(), "style": t.style, "totals": t.totals_row, "columns": t.columns.iter().map(|c| c.name.clone()).collect::<Vec<_>>()})).collect::<Vec<_>>(),
|
||||
"charts": sh.charts.iter().map(|c| json!({"id": c.id, "kind": format!("{:?}", c.kind), "title": c.title, "at": c.anchor.cell.a1(), "series": c.series.len(), "trendlines": c.series.iter().flat_map(|s| s.trendlines.iter().map(|t| format!("{:?}", t.kind))).collect::<Vec<_>>()})).collect::<Vec<_>>(),
|
||||
"charts": sh.charts.iter().map(|c| json!({"id": c.id, "kind": format!("{:?}", c.kind), "title": c.title, "at": c.anchor.cell.a1(), "series": c.series.len(), "dataTable": c.data_table.as_ref().map(|t| json!({"showKeys": t.show_keys, "horizontalBorder": t.horizontal_border, "verticalBorder": t.vertical_border, "outline": t.outline})), "trendlines": c.series.iter().flat_map(|s| s.trendlines.iter().map(|t| format!("{:?}", t.kind))).collect::<Vec<_>>()})).collect::<Vec<_>>(),
|
||||
"images": sh.images.len(),
|
||||
"shapes": sh.shapes.len(),
|
||||
"conditionalFormats": sh.cond_formats.len(),
|
||||
@@ -137,6 +137,13 @@ fn cell_get(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
"merge": sh.merge_at(c).map(|m| m.a1()),
|
||||
"comment": sh.comments.get(&c),
|
||||
"hyperlink": sh.hyperlinks.get(&c),
|
||||
"python": cell.and_then(|x| x.py.as_deref()).map(|py| json!({
|
||||
"code": py.code,
|
||||
"output": if py.object { "object" } else { "value" },
|
||||
"typeName": py.output.as_ref().map(|o| o.type_name.as_str()),
|
||||
"preview": py.output.as_ref().map(|o| o.preview.as_str()),
|
||||
"stale": py.stale,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ pub(super) fn insert_pivot_chart(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
series,
|
||||
legend: if matches!(kind, ChartKind::Pie | ChartKind::Doughnut) { LegendPos::Right } else { LegendPos::Bottom },
|
||||
data_labels: false,
|
||||
data_table: None,
|
||||
gridlines: !matches!(kind, ChartKind::Pie | ChartKind::Doughnut | ChartKind::Treemap | ChartKind::Sunburst | ChartKind::Funnel),
|
||||
style: 1,
|
||||
x_title: None,
|
||||
|
||||
@@ -255,7 +255,10 @@ pub(super) fn show_details(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
for (c, h) in src.headers.iter().enumerate() {
|
||||
sh.set_cell(CellRef::new(0, c as u32), Cell { value: gridcraft_core::Value::text(h.as_str()), formula: None, style: StyleId::default() });
|
||||
sh.set_cell(
|
||||
CellRef::new(0, c as u32),
|
||||
Cell { value: gridcraft_core::Value::text(h.as_str()), formula: None, style: StyleId::default(), py: None },
|
||||
);
|
||||
}
|
||||
for (r, i) in records.iter().enumerate() {
|
||||
let Some(rec) = src.rows.get(*i) else { continue };
|
||||
@@ -264,7 +267,7 @@ pub(super) fn show_details(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if v.is_empty() && style == StyleId::default() {
|
||||
continue;
|
||||
}
|
||||
sh.set_cell(CellRef::new(r as u32 + 1, c as u32), Cell { value: v.clone(), formula: None, style });
|
||||
sh.set_cell(CellRef::new(r as u32 + 1, c as u32), Cell { value: v.clone(), formula: None, style, py: None });
|
||||
}
|
||||
}
|
||||
let name = sh.name.clone();
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
//! Printing: Page Setup, pagination and PDF output (File › Print, File › Export › PDF).
|
||||
//!
|
||||
//! Sheets are paginated like Excel's default "down, then over": the print area (or the used
|
||||
//! range plus drawing objects) is split into bands of rows and columns that fit the printable
|
||||
//! area at the page scale, honouring manual breaks, repeated title rows/columns, headings, Fit
|
||||
//! To and centring. Each page is drawn into a PDF with `gridcraft-pdf`: fills, gridlines,
|
||||
//! borders, formatted text (fonts mapped to the standard 14 PDF fonts), merged cells, wrapping,
|
||||
//! overflow into empty neighbours, conditional formats, table styles, charts, images, shapes and
|
||||
//! sparklines. Headers and footers support `&L`/`&C`/`&R` sections and the `&P &N &D &T &A &F &Z`
|
||||
//! codes.
|
||||
//! Sheets are paginated like Excel: the print area (or the used range plus drawing objects) is
|
||||
//! split into bands of rows and columns that fit the printable area at the page scale, honouring
|
||||
//! manual breaks, repeated title rows/columns, headings, Fit To and centring, and the pages follow
|
||||
//! the sheet's page order ("down, then over" by default, or "over, then down"). Each page is
|
||||
//! drawn into a PDF with `gridcraft-pdf`: fills, gridlines, borders, formatted text (fonts mapped
|
||||
//! to the standard 14 PDF fonts), merged cells, wrapping, overflow into empty neighbours,
|
||||
//! conditional formats, table styles, charts, images, shapes and sparklines. Headers and footers
|
||||
//! support `&L`/`&C`/`&R` sections and the `&P &N &D &T &A &F &Z` codes.
|
||||
|
||||
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value};
|
||||
#[cfg(test)]
|
||||
use gridcraft_model::{Alignment, Image, SheetViewMode};
|
||||
use gridcraft_model::{
|
||||
Anchor, BorderLine, BorderStyle, Borders, Color, HAlign, Orientation, PatternType, PrintSettings, ShapeKind, Sheet, Style, StyleId, StyleTable,
|
||||
Underline, VAlign, Visibility, Workbook,
|
||||
Anchor, BorderLine, BorderStyle, Borders, Color, HAlign, Orientation, PageOrder, PatternType, PrintSettings, ShapeKind, Sheet, Style, StyleId,
|
||||
StyleTable, Underline, VAlign, Visibility, Workbook,
|
||||
};
|
||||
use gridcraft_pdf::{Font, Page, PdfDoc, Point, Rect, Rgb};
|
||||
use serde_json::{Value as Json, json};
|
||||
@@ -69,7 +69,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Print Preview",
|
||||
["File"],
|
||||
None,
|
||||
"{page? (1-based), sheets?: active|all, range?, fitToPage?} → {pages, pageList: [{page, sheet, range, rows, columns, scale, paper, orientation, width, height}]}",
|
||||
"{page? (1-based), sheets?: active|all, range?, fitToPage?} → {pages, pageList: [{page, sheet, range, rows, columns, scale, paper, orientation, pageOrder, width, height}]} in print order",
|
||||
has_doc,
|
||||
print_preview
|
||||
),
|
||||
@@ -78,7 +78,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Page Setup…",
|
||||
["Page Layout", "Page Setup"],
|
||||
None,
|
||||
"{sheet?, orientation?: portrait|landscape, paper?: Letter|Legal|A4|A3|A5|A6|Tabloid|Executive|Ledger|B4|B5|B6|B4 (ISO)|B5 (ISO)|B6 (ISO)|Japanese Postcard|Japanese Double Postcard|Japanese Envelope #3|Japanese Envelope #4|Japanese Envelope Kaku #2|Japanese Envelope Kaku #3 (B4/B5/B6 are the JIS sizes), margins?: {left,right,top,bottom,header,footer} (inches; also accepted as top-level keys), scale?: 10..400, fitWidth?, fitHeight? (pages; 0 = automatic), fitToPage?: bool, centerH?, centerV?, gridlines?, headings?, header?: \"&L&A&RPage &P of &N\", footer?, printArea?: \"A1:F50\" (\"\" clears), titleRows?: \"1:2\", titleCols?: \"A:A\", rowBreaks?: [46, 91], colBreaks?: [\"K\"]} → the sheet's print settings",
|
||||
"{sheet?, orientation?: portrait|landscape, paper?: Letter|Legal|A4|A3|A5|A6|Tabloid|Executive|Ledger|B4|B5|B6|B4 (ISO)|B5 (ISO)|B6 (ISO)|Japanese Postcard|Japanese Double Postcard|Japanese Envelope #3|Japanese Envelope #4|Japanese Envelope Kaku #2|Japanese Envelope Kaku #3 (B4/B5/B6 are the JIS sizes), margins?: {left,right,top,bottom,header,footer} (inches; also accepted as top-level keys), scale?: 10..400, fitWidth?, fitHeight? (pages; 0 = automatic), fitToPage?: bool, centerH?, centerV?, gridlines?, headings?, header?: \"&L&A&RPage &P of &N\", footer?, printArea?: \"A1:F50\" (\"\" clears), titleRows?: \"1:2\", titleCols?: \"A:A\", rowBreaks?: [46, 91], colBreaks?: [\"K\"], pageOrder?: downThenOver|overThenDown} → the sheet's print settings",
|
||||
has_doc,
|
||||
page_setup
|
||||
),
|
||||
@@ -222,6 +222,7 @@ fn print_preview(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
"scale": (lay.scale * 1000.0).round() / 10.0,
|
||||
"paper": lay.settings.paper,
|
||||
"orientation": if lay.settings.orientation == Orientation::Landscape { "landscape" } else { "portrait" },
|
||||
"pageOrder": lay.settings.page_order.id(),
|
||||
"width": lay.page_w,
|
||||
"height": lay.page_h,
|
||||
}));
|
||||
@@ -244,6 +245,16 @@ fn page_setup(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Some("landscape") => Some(Orientation::Landscape),
|
||||
Some(o) => return Err(bad(CMD, format!("orientation must be portrait or landscape, not `{o}`"))),
|
||||
};
|
||||
let page_order = match p.get("pageOrder") {
|
||||
None => None,
|
||||
Some(v) => match v.as_str().and_then(PageOrder::parse) {
|
||||
Some(o) => Some(o),
|
||||
None => {
|
||||
let shown = v.as_str().map_or_else(|| v.to_string(), str::to_string);
|
||||
return Err(bad(CMD, format!("pageOrder must be downThenOver or overThenDown, not `{shown}`")));
|
||||
}
|
||||
},
|
||||
};
|
||||
let paper = match str_param(p, "paper") {
|
||||
None => None,
|
||||
Some(name) => {
|
||||
@@ -385,6 +396,9 @@ fn page_setup(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if let Some(b) = &col_breaks {
|
||||
pr.col_breaks = b.clone();
|
||||
}
|
||||
if let Some(o) = page_order {
|
||||
pr.page_order = o;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let pr = s.doc()?.wb.sheet(si).map(|sh| sh.print.clone()).unwrap_or_default();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::header_footer::HF_FIXED_SIDE_MARGIN;
|
||||
use super::{
|
||||
Anchor, CellRef, Json, MAX_COLS, MAX_PAGES, MAX_ROWS, Orientation, PX_TO_PT, PrintSettings, RangeRef, Result, Sheet, Visibility, Workbook, bad,
|
||||
bool_param, split_sheet, str_param,
|
||||
Anchor, CellRef, Json, MAX_COLS, MAX_PAGES, MAX_ROWS, Orientation, PX_TO_PT, PageOrder, PrintSettings, RangeRef, Result, Sheet, Visibility,
|
||||
Workbook, bad, bool_param, split_sheet, str_param,
|
||||
};
|
||||
|
||||
/// Cells in a page block above which only stored cells are visited (not every grid position).
|
||||
@@ -253,15 +253,7 @@ pub fn layout_sheet(wb: &Workbook, si: usize, opts: &PrintOptions, range_overrid
|
||||
let paginate = |lay: &mut SheetLayout, manual: bool| {
|
||||
lay.manual_breaks = manual;
|
||||
(lay.row_bands, lay.col_bands) = split_bands(sh, lay, MAX_PAGES);
|
||||
lay.pages.clear();
|
||||
'outer: for ci in 0..lay.col_bands.len() {
|
||||
for ri in 0..lay.row_bands.len() {
|
||||
if lay.pages.len() >= MAX_PAGES {
|
||||
break 'outer;
|
||||
}
|
||||
lay.pages.push((ri, ci));
|
||||
}
|
||||
}
|
||||
lay.pages = page_sequence(lay.row_bands.len(), lay.col_bands.len(), lay.settings.page_order, MAX_PAGES);
|
||||
};
|
||||
match fit {
|
||||
Some((fw, fh)) => {
|
||||
@@ -290,6 +282,25 @@ pub fn layout_sheet(wb: &Workbook, si: usize, opts: &PrintOptions, range_overrid
|
||||
Some(lay)
|
||||
}
|
||||
|
||||
/// The pages of a sheet `rows` pages tall and `cols` pages wide as (row band, column band), in
|
||||
/// print order, at most `cap` of them: down each column of pages, then over to the next (Excel's
|
||||
/// default), or over each row of pages, then down to the next.
|
||||
pub(crate) fn page_sequence(rows: usize, cols: usize, order: PageOrder, cap: usize) -> Vec<(usize, usize)> {
|
||||
let (outer, inner) = match order {
|
||||
PageOrder::DownThenOver => (cols, rows),
|
||||
PageOrder::OverThenDown => (rows, cols),
|
||||
};
|
||||
(0..outer)
|
||||
.flat_map(|o| {
|
||||
(0..inner).map(move |i| match order {
|
||||
PageOrder::DownThenOver => (i, o),
|
||||
PageOrder::OverThenDown => (o, i),
|
||||
})
|
||||
})
|
||||
.take(cap)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Inclusive-start/end index spans of the pages along one axis.
|
||||
pub(crate) type Bands = Vec<(u32, u32)>;
|
||||
|
||||
|
||||
@@ -29,6 +29,26 @@ pub(super) fn draw_cell_picture(pg: &mut Page, data: &[u8], rect: (f32, f32, f32
|
||||
}
|
||||
|
||||
/// Sheet pixel rectangle of an anchor.
|
||||
/// Most source lines printed for an equation that has no layout.
|
||||
pub(super) const MAX_FALLBACK_LINES: usize = 500;
|
||||
|
||||
/// The lines of `text` printed in place of an equation that has no layout: those of its first
|
||||
/// `MAX_SOURCE_BYTES` (a longer source never lays out, and a file can carry megabytes) that fit a
|
||||
/// frame `h` points tall at `line_h` points per line (at least the first), and never more than
|
||||
/// [`MAX_FALLBACK_LINES`]. A single `pg.text` call would print every newline as `?`.
|
||||
pub(super) fn fallback_lines(text: &str, h: f32, line_h: f32) -> impl Iterator<Item = &str> {
|
||||
let text = crate::cmd::equation::source_excerpt(text);
|
||||
let fit = h / line_h;
|
||||
let n = if fit >= MAX_FALLBACK_LINES as f32 {
|
||||
MAX_FALLBACK_LINES
|
||||
} else if fit >= 1.0 {
|
||||
fit as usize
|
||||
} else {
|
||||
1
|
||||
};
|
||||
text.lines().take(n)
|
||||
}
|
||||
|
||||
fn anchor_rect(sh: &Sheet, a: &Anchor) -> (f64, f64, f64, f64) {
|
||||
(sh.col_left(a.cell.col) + a.dx as f64, sh.row_top(a.cell.row) + a.dy as f64, a.width.max(0.0) as f64, a.height.max(0.0) as f64)
|
||||
}
|
||||
@@ -79,6 +99,25 @@ pub(super) fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet,
|
||||
continue;
|
||||
}
|
||||
let (x, y, w, h) = place(r);
|
||||
if shp.kind == ShapeKind::Equation {
|
||||
let ink = rgb_of(shp.line, wb).unwrap_or([0, 0, 0]);
|
||||
match shp.math.get(&shp.text) {
|
||||
Some(Ok(list)) => crate::cmd::equation::draw_pdf(pg, list, (x, y, w, h), crate::cmd::equation::PADDING * b.k, ink),
|
||||
_ => {
|
||||
// No layout (the source does not parse, or the file was past the layout
|
||||
// budget): the source, one text run per line, as many as the frame holds.
|
||||
let line_h = 12.0 * b.scale;
|
||||
// A long line stays inside the frame; this clip nests in the block's.
|
||||
pg.save();
|
||||
pg.clip_rect(x, y, w, h);
|
||||
for (i, line) in fallback_lines(&shp.text, h, line_h).enumerate() {
|
||||
pg.text(x + 2.0 * b.k, y + line_h * (i + 1) as f32, 10.0 * b.scale, Font::Courier, ink, line);
|
||||
}
|
||||
pg.restore();
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
draw_shape(pg, wb, shp, b, (x, y, w, h));
|
||||
}
|
||||
for ch in &sh.charts {
|
||||
|
||||
@@ -16,6 +16,25 @@ fn contains(hay: &[u8], needle: &str) -> bool {
|
||||
hay.windows(needle.len()).any(|w| w == needle.as_bytes())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chart_data_table_prints_formatted_values_and_follows_recalculation() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Quarter", "Sales"], ["Q1", 1234], ["Q2", 5678]]})).unwrap();
|
||||
s.execute("home.numberFormat", json!({"range": "B2:B3", "format": "#,##0.00"})).unwrap();
|
||||
s.execute("insert.chart", json!({"range": "A1:B3", "at": "D2", "type": "column"})).unwrap();
|
||||
let (off, pages) = pdf_of(&mut s, json!({"fitToPage": true}));
|
||||
assert_eq!(pages, 1);
|
||||
let count = |bytes: &[u8], text: &str| String::from_utf8_lossy(bytes).matches(text).count();
|
||||
s.execute("chart.set", json!({"dataTable": true})).unwrap();
|
||||
let (on, _) = pdf_of(&mut s, json!({"fitToPage": true}));
|
||||
assert_eq!(count(&on, "(1,234.00) Tj"), count(&off, "(1,234.00) Tj") + 1);
|
||||
assert_eq!(count(&on, "(5,678.00) Tj"), count(&off, "(5,678.00) Tj") + 1);
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "=1000+111"})).unwrap();
|
||||
let (updated, _) = pdf_of(&mut s, json!({"fitToPage": true}));
|
||||
assert!(!contains(&updated, "(1,234.00) Tj"));
|
||||
assert!(count(&updated, "(1,111.00) Tj") >= 2, "cell and chart data table both print the new value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_export_keeps_safe_links_on_their_printed_pages() {
|
||||
let mut s = s();
|
||||
@@ -148,6 +167,142 @@ fn wide_sheet_goes_down_then_over() {
|
||||
assert_eq!(pv["pageList"][2]["range"], "K1:O45");
|
||||
}
|
||||
|
||||
/// The ranges of the printed pages, in print order.
|
||||
fn page_ranges(s: &mut Session) -> Vec<String> {
|
||||
let pv = s.execute("file.printPreview", json!({})).unwrap();
|
||||
let list = pv["pageList"].as_array().unwrap();
|
||||
assert_eq!(pv["pages"], list.len());
|
||||
for (i, page) in list.iter().enumerate() {
|
||||
assert_eq!(page["page"], i + 1);
|
||||
assert_eq!(page["pageOrder"], s.doc().unwrap().wb.active().unwrap().print.page_order.id());
|
||||
}
|
||||
list.iter().map(|p| p["range"].as_str().unwrap().to_string()).collect()
|
||||
}
|
||||
|
||||
/// The content streams of an exported PDF's pages, in page order (pages without pictures are the
|
||||
/// file's only streams).
|
||||
fn pdf_pages(s: &mut Session) -> Vec<String> {
|
||||
let (pdf, pages) = pdf_of(s, json!({}));
|
||||
let text = String::from_utf8_lossy(&pdf);
|
||||
let streams: Vec<String> = text.split("endstream").filter(|part| part.contains("stream\n")).map(str::to_string).collect();
|
||||
assert_eq!(streams.len() as u64, pages);
|
||||
streams
|
||||
}
|
||||
|
||||
/// The page order measured in Excel: print area A1:D4 with a row break before row 3 and a column
|
||||
/// break before column C, so four pages two tall and two wide, each filled and labelled in its
|
||||
/// own colour, with `Page &P of &N` in the footer.
|
||||
fn two_by_two_pages() -> Session {
|
||||
let mut s = s();
|
||||
for (range, color, label) in
|
||||
[("A1:B2", "#FF0000", "red"), ("A3:B4", "#00FF00", "green"), ("C1:D2", "#0000FF", "blue"), ("C3:D4", "#FFFF00", "yellow")]
|
||||
{
|
||||
s.execute("home.fillColor", json!({"range": range, "color": color})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": &range[..2], "input": label})).unwrap();
|
||||
}
|
||||
s.execute("pageLayout.pageSetup", json!({"printArea": "A1:D4", "rowBreaks": [3], "colBreaks": ["C"], "footer": "Page &P of &N"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// The colour of each printed page, read from its fill and label; each page's footer must
|
||||
/// number it in print order.
|
||||
fn printed_colours(s: &mut Session) -> Vec<&'static str> {
|
||||
let pages = pdf_pages(s);
|
||||
let n = pages.len();
|
||||
pages
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, page)| {
|
||||
assert!(page.contains(&format!("(Page {} of {n}) Tj", i + 1)), "page {}: {page}", i + 1);
|
||||
let found: Vec<&str> = [("red", "1 0 0 rg"), ("green", "0 1 0 rg"), ("blue", "0 0 1 rg"), ("yellow", "1 1 0 rg")]
|
||||
.into_iter()
|
||||
.filter(|(label, fill)| page.contains(&format!("({label}) Tj")) && page.contains(&format!("\n{fill}\n")))
|
||||
.map(|(label, _)| label)
|
||||
.collect();
|
||||
assert_eq!(found.len(), 1, "page {}: {found:?}", i + 1);
|
||||
found[0]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_order_sets_the_sequence_of_the_pages_as_measured_in_excel() {
|
||||
let mut s = two_by_two_pages();
|
||||
// Down, then over is the default: down the first column of pages, then the next.
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().print.page_order, PageOrder::DownThenOver);
|
||||
assert_eq!(s.execute("pageLayout.pageSetup", json!({})).unwrap()["page_order"], "downThenOver");
|
||||
let down = ["A1:B2", "A3:B4", "C1:D2", "C3:D4"];
|
||||
assert_eq!(page_ranges(&mut s), down);
|
||||
assert_eq!(printed_colours(&mut s), ["red", "green", "blue", "yellow"]);
|
||||
// Over, then down: across the first row of pages, then the next.
|
||||
let r = s.execute("pageLayout.pageSetup", json!({"pageOrder": "overThenDown"})).unwrap();
|
||||
assert_eq!(r["page_order"], "overThenDown");
|
||||
let over = ["A1:B2", "C1:D2", "A3:B4", "C3:D4"];
|
||||
assert_eq!(page_ranges(&mut s), over);
|
||||
assert_eq!(printed_colours(&mut s), ["red", "blue", "green", "yellow"]);
|
||||
assert_eq!(s.execute("file.printPreview", json!({"page": 2})).unwrap()["page"]["range"], "C1:D2");
|
||||
// One undo step.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(page_ranges(&mut s), down);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(page_ranges(&mut s), over);
|
||||
// Anything but the two orders is refused and changes nothing, even next to a good value.
|
||||
for bad in [json!("sideways"), json!("OverThenDown"), json!(""), json!(2), json!(null), json!(true), json!(["downThenOver"])] {
|
||||
let e = s.execute("pageLayout.pageSetup", json!({"pageOrder": bad, "orientation": "landscape"})).unwrap_err();
|
||||
assert!(e.to_string().contains("pageOrder"), "{bad}: {e}");
|
||||
}
|
||||
let pr = &s.doc().unwrap().wb.active().unwrap().print;
|
||||
assert_eq!((pr.page_order, pr.orientation), (PageOrder::OverThenDown, Orientation::Portrait));
|
||||
assert!(s.take_ui_requests().is_empty(), "a programmatic call never opens a dialog");
|
||||
// First-page and odd/even headers go by the printed sequence too.
|
||||
s.execute(
|
||||
"pageLayout.headerFooter",
|
||||
json!({"header": "&COdd &P", "evenHeader": "&CEven &P", "firstHeader": "&CCover", "differentFirst": true, "differentOddEven": true}),
|
||||
)
|
||||
.unwrap();
|
||||
let pages = pdf_pages(&mut s);
|
||||
for (page, (header, label)) in pages.iter().zip([("Cover", "red"), ("Even 2", "blue"), ("Odd 3", "green"), ("Even 4", "yellow")]) {
|
||||
assert!(page.contains(&format!("({header}) Tj")) && page.contains(&format!("({label}) Tj")), "{header}: {page}");
|
||||
}
|
||||
s.execute("pageLayout.pageSetup", json!({"pageOrder": "downThenOver"})).unwrap();
|
||||
assert_eq!(page_ranges(&mut s), down);
|
||||
let pages = pdf_pages(&mut s);
|
||||
for (page, (header, label)) in pages.iter().zip([("Cover", "red"), ("Even 2", "green"), ("Odd 3", "blue"), ("Even 4", "yellow")]) {
|
||||
assert!(page.contains(&format!("({header}) Tj")) && page.contains(&format!("({label}) Tj")), "{header}: {page}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_order_without_breaks_and_with_print_titles() {
|
||||
let mut s = s();
|
||||
// Every cell shows its own address: 60 rows and 15 columns are two pages tall and two wide.
|
||||
let rows: Vec<Vec<Json>> = (1..=60).map(|r| (0..15).map(|c| json!(format!("{}{r}", gridcraft_core::col_to_letters(c)))).collect()).collect();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
|
||||
for titles in [false, true] {
|
||||
if titles {
|
||||
s.execute("pageLayout.pageSetup", json!({"titleRows": "1:1", "titleCols": "A:A"})).unwrap();
|
||||
}
|
||||
s.execute("pageLayout.pageSetup", json!({"pageOrder": "downThenOver"})).unwrap();
|
||||
let down = page_ranges(&mut s);
|
||||
if !titles {
|
||||
assert_eq!(down, ["A1:J45", "A46:J60", "K1:O45", "K46:O60"]);
|
||||
}
|
||||
s.execute("pageLayout.pageSetup", json!({"pageOrder": "overThenDown"})).unwrap();
|
||||
let over = page_ranges(&mut s);
|
||||
assert_eq!(over, [down[0].clone(), down[2].clone(), down[1].clone(), down[3].clone()], "titles: {titles}");
|
||||
// Each printed page starts with its own top-left cell; the title row and column (A1 is in
|
||||
// both) repeat on every page in either order.
|
||||
for (order, ranges) in [("overThenDown", &over), ("downThenOver", &down)] {
|
||||
s.execute("pageLayout.pageSetup", json!({"pageOrder": order})).unwrap();
|
||||
for (i, (page, range)) in pdf_pages(&mut s).iter().zip(ranges.iter()).enumerate() {
|
||||
let top_left = range.split(':').next().unwrap();
|
||||
assert!(page.contains(&format!("({top_left}) Tj")), "{order} page {}: no {top_left}", i + 1);
|
||||
assert_eq!(page.contains("(A1) Tj"), titles || i == 0, "{order} page {}, titles: {titles}", i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_and_even_pages_print_their_own_headers() {
|
||||
let mut s = s();
|
||||
@@ -174,6 +329,14 @@ fn first_and_even_pages_print_their_own_headers() {
|
||||
assert!(contains(&pdf, "(Odd 1) Tj") && contains(&pdf, "(Odd 2) Tj") && !contains(&pdf, "(Cover) Tj"));
|
||||
}
|
||||
|
||||
/// The y position of the first `(text) Tj` in a PDF (PDF space: larger is higher on the page).
|
||||
fn text_y(pdf: &[u8], text: &str) -> Option<f32> {
|
||||
let s = String::from_utf8_lossy(pdf);
|
||||
let before = &s[..s.find(&format!("({text}) Tj"))?];
|
||||
let tm = before.rfind(" Tm")?;
|
||||
before[..tm].rsplit([' ', '\n']).next()?.parse().ok()
|
||||
}
|
||||
|
||||
/// The x position and font size of the first `(text) Tj` in a PDF.
|
||||
fn text_at(pdf: &[u8], text: &str) -> Option<(f32, f32)> {
|
||||
let s = String::from_utf8_lossy(pdf);
|
||||
@@ -473,6 +636,11 @@ fn hostile_settings_never_panic_and_cap_pages() {
|
||||
assert_eq!(pv["pages"].as_u64().unwrap(), MAX_PAGES as u64);
|
||||
let r = s.execute("file.exportPdf", json!({})).unwrap();
|
||||
assert_eq!(r["pages"], MAX_PAGES);
|
||||
// Over, then down stops at the cap as well, along the first row of pages.
|
||||
s.execute("pageLayout.pageSetup", json!({"pageOrder": "overThenDown"})).unwrap();
|
||||
let pv = s.execute("file.printPreview", json!({})).unwrap();
|
||||
assert_eq!(pv["pages"].as_u64().unwrap(), MAX_PAGES as u64);
|
||||
assert_eq!(pv["pageList"][1]["rows"], pv["pageList"][0]["rows"], "the second page is beside the first");
|
||||
s.execute("pageLayout.pageSetup", json!({"fitWidth": 1, "fitHeight": 1, "titleRows": "1:1048576", "titleCols": "A:XFD", "headings": true}))
|
||||
.unwrap();
|
||||
let pv = s.execute("file.printPreview", json!({})).unwrap();
|
||||
@@ -581,3 +749,113 @@ fn checkbox_cells_print_as_boxes_not_true_false() {
|
||||
assert!(contains(&pdf, "0.063 0.486 0.255 rg"), "the checked box is not filled");
|
||||
assert!(contains(&pdf, "0.431 0.431 0.431 RG"), "the unchecked box is not outlined");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_equation_without_a_layout_prints_its_source_one_line_per_line() {
|
||||
use super::objects::{MAX_FALLBACK_LINES, fallback_lines};
|
||||
let mut s = s();
|
||||
s.execute("insert.equation", json!({"latex": "x", "cell": "B2"})).unwrap();
|
||||
// A file can carry a source the layout engine refuses: two lines here, in a frame 100 px
|
||||
// (75 pt) tall, which holds six 12 pt lines.
|
||||
let with_source = |text: &str| {
|
||||
let mut wb = (*s.doc().unwrap().wb).clone();
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
for x in sh.shapes.iter_mut() {
|
||||
x.text = text.into();
|
||||
x.anchor.height = 100.0;
|
||||
}
|
||||
}
|
||||
let mut t = Session::new();
|
||||
t.add_document(crate::DocState::new(wb, None, "Book".into(), crate::Trust::FromFile));
|
||||
pdf_of(&mut t, json!({})).0
|
||||
};
|
||||
let pdf = with_source("frac{\nsecond line");
|
||||
assert!(contains(&pdf, "(frac{) Tj"), "the first line is its own text run");
|
||||
assert!(contains(&pdf, "(second line) Tj"), "and so is the second");
|
||||
assert!(!contains(&pdf, "(frac{?second line) Tj"), "the newline is not printed as ?");
|
||||
let (y1, y2) = (text_y(&pdf, "frac{").unwrap(), text_y(&pdf, "second line").unwrap());
|
||||
assert!(y1 > y2 && y1 - y2 < 13.0, "the second line sits one line below the first: {y1} {y2}");
|
||||
// Lines the frame cannot hold are not printed.
|
||||
let pdf = with_source(&"l\n".repeat(10_000));
|
||||
assert_eq!(String::from_utf8_lossy(&pdf).matches("(l) Tj").count(), 6);
|
||||
// Whatever the frame, the count is capped; a frame too low for one line still shows the first.
|
||||
let tall = "l\n".repeat(10_000);
|
||||
assert_eq!(fallback_lines(&tall, f32::INFINITY, 12.0).count(), MAX_FALLBACK_LINES);
|
||||
assert_eq!(fallback_lines(&tall, 1.0e9, 12.0).count(), MAX_FALLBACK_LINES);
|
||||
assert_eq!(fallback_lines("a\nb\nc", 30.0, 12.0).count(), 2);
|
||||
assert_eq!(fallback_lines("a\nb", 0.0, 12.0).count(), 1);
|
||||
assert_eq!(fallback_lines("a\nb", f32::NAN, 12.0).count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fallback_text_is_clipped_to_the_equation_frame() {
|
||||
// A long line of a source that does not lay out, in a narrow frame (40 by 100 px, 30 by
|
||||
// 75 pt): the lines are drawn inside a clip of the frame, nested in the block's, so they
|
||||
// cannot run over neighbours, and the clip is lifted again before the next object (a text
|
||||
// box below the equation in the same block) is drawn.
|
||||
let mut s = s();
|
||||
s.execute("insert.equation", json!({"latex": "x", "cell": "B2"})).unwrap();
|
||||
s.execute("insert.textBox", json!({"at": "B12", "text": "later box"})).unwrap();
|
||||
let mut wb = (*s.doc().unwrap().wb).clone();
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
for x in sh.shapes.iter_mut().filter(|x| x.kind == gridcraft_model::ShapeKind::Equation) {
|
||||
x.text = format!("frac{{{}", "l".repeat(200));
|
||||
x.anchor.width = 40.0;
|
||||
x.anchor.height = 100.0;
|
||||
}
|
||||
}
|
||||
let mut t = Session::new();
|
||||
t.add_document(crate::DocState::new(wb, None, "Book".into(), crate::Trust::FromFile));
|
||||
let (pdf, _) = pdf_of(&mut t, json!({}));
|
||||
let text = String::from_utf8_lossy(&pdf);
|
||||
let clip = text.find("30 75 re W n").expect("a clip the size of the frame");
|
||||
let run = text.find("(frac{llll").expect("the source line");
|
||||
let later = text.find("(later box) Tj").expect("the text box after the equation");
|
||||
assert!(clip < run && run < later, "clip, then the text, then the later object");
|
||||
// The net number of saved graphics states (`q` less `Q`) before an operator.
|
||||
let depth = |at: usize| text[..at].lines().filter(|l| *l == "q").count() as i64 - text[..at].lines().filter(|l| *l == "Q").count() as i64;
|
||||
assert_eq!(depth(run), depth(clip), "the text is drawn under the frame's clip");
|
||||
assert_eq!(depth(later), depth(clip) - 1, "the later object is drawn with the frame's state restored");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::disallowed_methods)] // tests run only off wasm, where the clock exists
|
||||
fn an_equation_source_of_a_megabyte_prints_a_bounded_excerpt() {
|
||||
// A file can carry a source far past `MAX_SOURCE_BYTES` (the drawing part may be 512 MiB). It
|
||||
// never lays out, so the fallback prints the first 4,096 bytes and does no work on the rest:
|
||||
// the PDF holds one run of that length, not the megabyte, and export takes bounded time.
|
||||
let mut s = s();
|
||||
s.execute("insert.equation", json!({"latex": "x", "cell": "B2"})).unwrap();
|
||||
let mut wb = (*s.doc().unwrap().wb).clone();
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
for x in sh.shapes.iter_mut() {
|
||||
x.text = "l".repeat(1 << 20);
|
||||
}
|
||||
}
|
||||
let mut t = Session::new();
|
||||
t.add_document(crate::DocState::new(wb, None, "Book".into(), crate::Trust::FromFile));
|
||||
let start = std::time::Instant::now();
|
||||
let (pdf, _) = pdf_of(&mut t, json!({}));
|
||||
let took = start.elapsed();
|
||||
let text = String::from_utf8_lossy(&pdf);
|
||||
let longest = text.split(|c| c != 'l').map(str::len).max().unwrap_or(0);
|
||||
assert_eq!(longest, gridcraft_math::MAX_SOURCE_BYTES, "the run is the excerpt and no more");
|
||||
assert!(pdf.len() < 64 * 1024, "the PDF holds the excerpt, not the megabyte: {} bytes", pdf.len());
|
||||
assert!(took < std::time::Duration::from_secs(5), "{took:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equations_print_with_the_symbol_font() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap();
|
||||
s.execute("insert.equation", json!({"latex": "\\sum_{i=1}^{n} \\alpha_i \\le \\infty \\pm \\sqrt{x} \\times \\partial y", "cell": "B2"}))
|
||||
.unwrap();
|
||||
s.execute("insert.equation", json!({"latex": "\\mathbb{R} \\oplus \\text{\u{4E2D}}", "cell": "B8"})).unwrap();
|
||||
let (pdf, _) = pdf_of(&mut s, json!({}));
|
||||
assert!(contains(&pdf, "/BaseFont /Symbol >>"), "maths symbols use the Symbol font");
|
||||
assert!(contains(&pdf, "/BaseFont /Times-Italic"), "variables are italic");
|
||||
// The summation (0xE5) and alpha (0x61) codes in Symbol.
|
||||
assert!(contains(&pdf, "(\\345) Tj") && contains(&pdf, "(a) Tj"));
|
||||
// A glyph no standard font has is a [?] box.
|
||||
assert!(contains(&pdf, "(?) Tj"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! Python in cells: making and editing Python cells, and their return type. This build stores
|
||||
//! the code and the last result a file cached; it doesn't run Python, so a cell with new or
|
||||
//! changed code shows `#BLOCKED!`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{CellRef, Value};
|
||||
use gridcraft_model::{Cell, PyCode, python::MAX_FORMULA_CHARS};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::{CommandSpec, bad, cell_param_on, cmd, edit, has_doc, str_param, target_sheet};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"python.set",
|
||||
"Set Python Code",
|
||||
[],
|
||||
None,
|
||||
"{cell?, code, output?: object|value} makes the cell (default: the active cell) a Python cell with this code; the code must fit in Excel's 8,192-character formula once written as PY(…). `output` is the return type: `value` (an Excel value, the default) or `object` (a Python object)",
|
||||
has_doc,
|
||||
set
|
||||
),
|
||||
cmd!(
|
||||
"python.output",
|
||||
"Python Output",
|
||||
[],
|
||||
Some("Ctrl+Alt+Shift+M"),
|
||||
"{cell?, mode?: object|value} sets the return type of a Python cell (default: the active cell): `object` (a Python object) or `value` (an Excel value). Without `mode` it switches to the other one. The cell keeps its code; a cached result of the old return type goes, as with `python.set`, so the cell shows #BLOCKED! until it runs",
|
||||
has_doc,
|
||||
output
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn target_cell(s: &Session, p: &Json) -> Result<(usize, CellRef)> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let at = match cell_param_on(s, p, "cell", sheet)? {
|
||||
Some(c) => c,
|
||||
None => s.doc()?.selection.active,
|
||||
};
|
||||
Ok((sheet, at))
|
||||
}
|
||||
|
||||
fn existing(s: &Session, sheet: usize, at: CellRef) -> Result<Option<PyCode>> {
|
||||
Ok(s.doc()?.wb.sheet(sheet).and_then(|sh| sh.cell(at)).and_then(|c| c.py.as_deref()).cloned())
|
||||
}
|
||||
|
||||
fn output_mode(cmd: &str, p: &Json, key: &str) -> Result<Option<bool>> {
|
||||
match p.get(key) {
|
||||
None | Some(Json::Null) => return Ok(None),
|
||||
Some(Json::String(_)) => {}
|
||||
Some(_) => return Err(bad(cmd, format!("`{key}` is `object` or `value`"))),
|
||||
}
|
||||
match str_param(p, key) {
|
||||
None => Ok(None),
|
||||
Some(m) if m.eq_ignore_ascii_case("object") => Ok(Some(true)),
|
||||
Some(m) if m.eq_ignore_ascii_case("value") => Ok(Some(false)),
|
||||
Some(m) => Err(bad(cmd, format!("`{key}` is `object` or `value`, not `{m}`"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (sheet, at) = target_cell(s, p)?;
|
||||
let code = match p.get("code") {
|
||||
Some(Json::String(c)) => c.as_str(),
|
||||
Some(_) => return Err(bad("python.set", "`code` must be text")),
|
||||
None => return Err(bad("python.set", "missing `code`")),
|
||||
};
|
||||
let mode = output_mode("python.set", p, "output")?;
|
||||
let old = existing(s, sheet, at)?;
|
||||
let object = mode.or(old.as_ref().map(|o| o.object)).unwrap_or(false);
|
||||
store(s, "python.set", sheet, at, PyCode { object, ..PyCode::new(code) }, old)?;
|
||||
let d = s.doc()?;
|
||||
let shown = d.wb.sheet(sheet).map(|sh| sh.value(at)).unwrap_or_default();
|
||||
Ok(json!({"cell": at.a1(), "value": crate::cmd::inspect::value_json(&shown)}))
|
||||
}
|
||||
|
||||
/// Writes `py` into the cell, which held `old`: what `python.set` and `python.output` share. The
|
||||
/// code must fit in one cell and a table header takes none; a locked cell on a protected sheet is
|
||||
/// refused by the edit itself.
|
||||
fn store(s: &mut Session, cmd: &str, sheet: usize, at: CellRef, mut py: PyCode, old: Option<PyCode>) -> Result<()> {
|
||||
if !py.fits() {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!("the code doesn't fit in one cell: written as PY(…) it would be longer than Excel's {MAX_FORMULA_CHARS}-character formula limit"),
|
||||
));
|
||||
}
|
||||
// A table header is the column's name, never a formula or code (as in Excel).
|
||||
let header =
|
||||
s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.tables.iter().any(|t| t.header_row && t.range.start.row == at.row && t.range.contains(at)));
|
||||
if header {
|
||||
return Err(EngineError::Other(format!("{} is a table header; Python cells can't go in a table's header row", at.a1())));
|
||||
}
|
||||
// Unchanged code with the same return type keeps its result (and its value); new code, or a
|
||||
// new return type, has none until it runs: a result of the other type would show a value and
|
||||
// preview that don't belong to the cell.
|
||||
let keep = old.filter(|o| o.code == py.code && o.object == py.object);
|
||||
let value = match &keep {
|
||||
Some(o) => {
|
||||
py.output = o.output.clone();
|
||||
py.stale = o.stale;
|
||||
None
|
||||
}
|
||||
None => Some(gridcraft_model::python::BLOCKED),
|
||||
};
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
let style = sh.style_id(at);
|
||||
let value = value.unwrap_or_else(|| sh.cell(at).map(|c| c.value.clone()).unwrap_or(Value::Empty));
|
||||
// A spill the cell had (a formula's, or the old result's) is left to the recalculation,
|
||||
// which removes it and recalculates what read it.
|
||||
sh.set_cell(at, Cell { value, formula: None, style, py: Some(Arc::new(py)) });
|
||||
cx.touch(sheet, at);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// `python.output`: the cell's return type. The code stays; a cached result belongs to the old
|
||||
/// return type, so it goes, as with `python.set` and a new return type: the cell shows
|
||||
/// `#BLOCKED!` until it runs, and its old spill goes with its readers recalculated.
|
||||
fn output(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (sheet, at) = target_cell(s, p)?;
|
||||
let Some(old) = existing(s, sheet, at)? else {
|
||||
return Err(EngineError::Other(format!("{} isn't a Python cell, so it has no Python output to change", at.a1())));
|
||||
};
|
||||
let object = output_mode("python.output", p, "mode")?.unwrap_or(!old.object);
|
||||
let mode = |object: bool| if object { "object" } else { "value" };
|
||||
if object != old.object {
|
||||
let py = PyCode { code: old.code.clone(), object, output: None, stale: false };
|
||||
store(s, "python.output", sheet, at, py, Some(old))?;
|
||||
}
|
||||
let d = s.doc()?;
|
||||
let shown = d.wb.sheet(sheet).map(|sh| sh.value(at)).unwrap_or_default();
|
||||
Ok(json!({"cell": at.a1(), "output": mode(object), "value": crate::cmd::inspect::value_json(&shown)}))
|
||||
}
|
||||
@@ -35,6 +35,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
label: "Allow Edit Ranges…",
|
||||
menu: &["Review", "Protect"],
|
||||
shortcut: None,
|
||||
shortcut_mac: None,
|
||||
params: "{action?: list (default) | add | modify | delete | unlock, title?, range?, password?, newTitle?, sheet?} → ranges",
|
||||
enabled: has_doc,
|
||||
run: allow_edit_ranges,
|
||||
|
||||
@@ -141,14 +141,7 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
|
||||
};
|
||||
// Floating objects: snapshot their bottom-right corners against the pre-shift geometry, so a
|
||||
// re-fit after the shift measures against the grid the object actually lands on.
|
||||
let obj_corners: Vec<(u32, u32, f32, f32)> = sh
|
||||
.charts
|
||||
.iter()
|
||||
.map(|o| &o.anchor)
|
||||
.chain(sh.images.iter().map(|o| &o.anchor))
|
||||
.chain(sh.shapes.iter().map(|o| &o.anchor))
|
||||
.map(|a| anchor_corner(sh, a))
|
||||
.collect();
|
||||
let obj_corners: Vec<(u32, u32, f32, f32)> = sh.object_anchors().map(|a| anchor_corner(sh, a)).collect();
|
||||
// Line info.
|
||||
let lines = if axis == Axis::Rows { &mut sh.rows } else { &mut sh.cols };
|
||||
let old = std::mem::take(lines);
|
||||
@@ -193,22 +186,13 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
|
||||
std::mem::take(&mut sh.cell_pictures).into_iter().filter_map(|(c, v)| map_cell(c).filter(CellRef::is_valid).map(|c| (c, v))).collect();
|
||||
sh.comments = std::mem::take(&mut sh.comments).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect();
|
||||
sh.hyperlinks = std::mem::take(&mut sh.hyperlinks).into_iter().filter_map(|(c, v)| map_cell(c).map(|c| (c, v))).collect();
|
||||
// Floating objects follow their anchor lines per their [`AnchorMode`]. Corners were snapshotted
|
||||
// as charts ++ images ++ shapes, matching the three slices below. New anchors are computed
|
||||
// against the post-shift grid first (the re-fit needs its sizes), then written back.
|
||||
let anchored = |ui: usize, a: Anchor| -> Anchor { obj_corners.get(ui).map_or(a, |&corner| shift_anchor(sh, a, corner, axis, at, &map_line)) };
|
||||
let new_charts: Vec<Anchor> = sh.charts.iter().enumerate().map(|(i, o)| anchored(i, o.anchor)).collect();
|
||||
let new_images: Vec<Anchor> = sh.images.iter().enumerate().map(|(i, o)| anchored(new_charts.len() + i, o.anchor)).collect();
|
||||
let base = new_charts.len() + new_images.len();
|
||||
let new_shapes: Vec<Anchor> = sh.shapes.iter().enumerate().map(|(i, o)| anchored(base + i, o.anchor)).collect();
|
||||
for (o, a) in sh.charts.iter_mut().zip(new_charts) {
|
||||
o.anchor = a;
|
||||
}
|
||||
for (o, a) in sh.images.iter_mut().zip(new_images) {
|
||||
o.anchor = a;
|
||||
}
|
||||
for (o, a) in sh.shapes.iter_mut().zip(new_shapes) {
|
||||
o.anchor = a;
|
||||
// Floating objects follow their anchor lines per their [`AnchorMode`]. New anchors are
|
||||
// computed against the post-shift grid first (the re-fit needs its sizes), then written back
|
||||
// in the same `object_anchors` order the corners were snapshotted in.
|
||||
let new_anchors: Vec<Anchor> =
|
||||
sh.object_anchors().zip(&obj_corners).map(|(a, &corner)| shift_anchor(sh, *a, corner, axis, at, &map_line)).collect();
|
||||
for (a, n) in sh.object_anchors_mut().zip(new_anchors) {
|
||||
*a = n;
|
||||
}
|
||||
sh.sparklines.retain_mut(|sp| match map_cell(sp.cell) {
|
||||
Some(c) => {
|
||||
@@ -231,11 +215,12 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrites every formula reference (and chart series) for a structural edit on `target`.
|
||||
pub(super) fn rewrite(cx: &mut Ctx, target: &str, e: &Edit) {
|
||||
/// Rewrites every formula reference (chart series included), sparkline source and PivotTable
|
||||
/// source for a structural edit on `target`.
|
||||
pub(super) fn rewrite(cx: &mut Ctx, target: &str, e: &Edit) -> Result<()> {
|
||||
cx.edits.push((target.to_string(), e.clone()));
|
||||
let wb = &mut cx.wb;
|
||||
super::edit::rewrite_all_formulas(wb, target, e);
|
||||
super::edit::rewrite_all_formulas(wb, target, e)?;
|
||||
for si in 0..wb.sheets.len() {
|
||||
let host = wb.sheets.get(si).map(|s| s.name.clone()).unwrap_or_default();
|
||||
let Some(sh) = wb.sheet_mut(si) else { continue };
|
||||
@@ -245,15 +230,6 @@ pub(super) fn rewrite(cx: &mut Ctx, target: &str, e: &Edit) {
|
||||
Err(_) => text.to_string(),
|
||||
}
|
||||
};
|
||||
for ch in sh.charts.iter_mut() {
|
||||
// The source block moves with its cells; one that was deleted is dropped.
|
||||
ch.source = ch.source.as_deref().map(fix).filter(|s| !s.contains("#REF!"));
|
||||
for se in ch.series.iter_mut() {
|
||||
se.values = fix(&se.values);
|
||||
se.categories = se.categories.as_deref().map(fix);
|
||||
se.name = se.name.as_deref().map(fix);
|
||||
}
|
||||
}
|
||||
for sp in sh.sparklines.iter_mut() {
|
||||
sp.source = fix(&sp.source);
|
||||
}
|
||||
@@ -270,6 +246,7 @@ pub(super) fn rewrite(cx: &mut Ctx, target: &str, e: &Edit) {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Inserting or deleting cells moves the copied block away from the stored source range, so, like
|
||||
@@ -327,7 +304,7 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
||||
// New lines take the format of the line before (Excel's "Format Same As Above").
|
||||
inherit_line_format(sh, axis, at, count);
|
||||
}
|
||||
rewrite(cx, &name, &Edit::Insert { axis, at, count });
|
||||
rewrite(cx, &name, &Edit::Insert { axis, at, count })?;
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
})?;
|
||||
@@ -392,7 +369,7 @@ fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
||||
}
|
||||
shift_sheet_features(sh, axis, at, count, false);
|
||||
}
|
||||
rewrite(cx, &name, &Edit::Delete { axis, at, count });
|
||||
rewrite(cx, &name, &Edit::Delete { axis, at, count })?;
|
||||
}
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
@@ -444,7 +421,7 @@ fn move_columns(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
sh.cells.shift_cols(before, count as i64);
|
||||
shift_sheet_features(sh, Axis::Cols, before, count, true);
|
||||
}
|
||||
rewrite(cx, &name, &Edit::Insert { axis: Axis::Cols, at: before, count });
|
||||
rewrite(cx, &name, &Edit::Insert { axis: Axis::Cols, at: before, count })?;
|
||||
|
||||
let mut touched = Vec::new();
|
||||
{
|
||||
@@ -482,14 +459,14 @@ fn move_columns(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
for cell in touched {
|
||||
cx.touch(sheet, cell);
|
||||
}
|
||||
rewrite(cx, &name, &Edit::Move { from: source, to_row: 0, to_col: destination });
|
||||
rewrite(cx, &name, &Edit::Move { from: source, to_row: 0, to_col: destination })?;
|
||||
|
||||
{
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
sh.cells.shift_cols(delete_at + count, -(count as i64));
|
||||
shift_sheet_features(sh, Axis::Cols, delete_at, count, false);
|
||||
}
|
||||
rewrite(cx, &name, &Edit::Delete { axis: Axis::Cols, at: delete_at, count });
|
||||
rewrite(cx, &name, &Edit::Delete { axis: Axis::Cols, at: delete_at, count })?;
|
||||
*cx.sel = crate::Selection {
|
||||
active: CellRef::new(active_row, final_start),
|
||||
anchor: CellRef::new(active_row, final_start),
|
||||
@@ -520,7 +497,7 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
} else {
|
||||
RangeRef::new(CellRef::new(r.start.row.max(MAX_ROWS.saturating_sub(r.height())), r.start.col), CellRef::new(MAX_ROWS - 1, r.end.col))
|
||||
};
|
||||
if sh.cells.iter_range(doomed).any(|(_, x)| x.formula.is_some() || !x.value.is_empty()) {
|
||||
if sh.cells.iter_range(doomed).any(|(_, x)| x.has_content()) {
|
||||
return Err(EngineError::Other("To prevent possible loss of data, GridCraft cannot shift nonblank cells off of the worksheet.".into()));
|
||||
}
|
||||
}
|
||||
@@ -534,7 +511,7 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
shift_cell_features(sh, &e);
|
||||
let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
|
||||
rewrite(cx, &name, &e);
|
||||
rewrite(cx, &name, &e)?;
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
})?;
|
||||
@@ -569,7 +546,7 @@ fn delete_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
shift_cell_features(sh, &e);
|
||||
}
|
||||
// References into the deleted cells become #REF!; the rest move.
|
||||
rewrite(cx, &name, &e);
|
||||
rewrite(cx, &name, &e)?;
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
})?;
|
||||
|
||||
@@ -63,6 +63,9 @@ pub(super) fn delete_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::cmd::edit::rewrite_python_refs(&mut cx.wb, &|_, e| gridcraft_formula::adjust::delete_sheet(e, &name, &order))?;
|
||||
// Chart series into the deleted sheet become #REF!, like formulas.
|
||||
crate::cmd::edit::rewrite_cell_links(&mut cx.wb, |_, e| gridcraft_formula::adjust::delete_sheet(e, &name, &order));
|
||||
cx.wb.names.retain(|n| n.scope != Some(i));
|
||||
for n in cx.wb.names.iter_mut() {
|
||||
if let Some(sc) = n.scope.as_mut()
|
||||
@@ -116,17 +119,7 @@ pub(super) fn rename_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let fix = |t: &str| {
|
||||
gridcraft_formula::parse(t)
|
||||
.map(|e| gridcraft_formula::print(&gridcraft_formula::adjust::rename_sheet(e, &old, &name)))
|
||||
.unwrap_or_else(|_| t.to_string())
|
||||
};
|
||||
for ch in sh.charts.iter_mut() {
|
||||
for se in ch.series.iter_mut() {
|
||||
se.values = fix(&se.values);
|
||||
se.categories = se.categories.as_deref().map(fix);
|
||||
se.name = se.name.as_deref().map(fix);
|
||||
}
|
||||
// A PivotChart follows its PivotTable's sheet.
|
||||
if let Some((sheet, pivot)) = ch.pivot.as_deref().and_then(crate::pivot::parse_chart_link)
|
||||
&& sheet.eq_ignore_ascii_case(&old)
|
||||
@@ -135,11 +128,13 @@ pub(super) fn rename_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::cmd::edit::rewrite_cell_links(&mut cx.wb, |_, e| gridcraft_formula::adjust::rename_sheet(e, &old, &name));
|
||||
for n in cx.wb.names.iter_mut() {
|
||||
if let Ok(e) = gridcraft_formula::parse_name(&n.formula) {
|
||||
n.formula = gridcraft_formula::print(&gridcraft_formula::adjust::rename_sheet(e, &old, &name));
|
||||
}
|
||||
}
|
||||
crate::cmd::edit::rewrite_python_refs(&mut cx.wb, &|_, e| gridcraft_formula::adjust::rename_sheet(e, &old, &name))?;
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
})
|
||||
@@ -157,6 +152,8 @@ pub(super) fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let Some(src) = cx.wb.sheets.get(i).cloned() else { return Err(bad("sheet.move", "no such sheet")) };
|
||||
if copy {
|
||||
let mut sh = (*src).clone();
|
||||
// The copy is a sheet of its own, not the one it was copied from.
|
||||
sh.uid = gridcraft_model::SheetUid::fresh();
|
||||
let base = sh.name.clone();
|
||||
// `Name (2)`: the name is shortened so the suffix fits in Excel's 31 characters.
|
||||
let name = (2..)
|
||||
@@ -170,6 +167,20 @@ pub(super) fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
sh.name = name;
|
||||
sh.tables.clear(); // table names must be unique
|
||||
sh.preserved = None; // kept file content (form controls, OLE objects…) stays with the original
|
||||
// Object ids are unique across the workbook (selection and the `{kind, id}` commands
|
||||
// rely on it), so the copied objects get fresh ones (free ones, even when a file used
|
||||
// the largest id).
|
||||
let count = sh.charts.len() + sh.images.len() + sh.shapes.len() + sh.slicers.len();
|
||||
let ids = cx.wb.free_object_ids(count);
|
||||
if ids.len() < count {
|
||||
return Err(bad("sheet.move", "no free object ids are left for the copied objects"));
|
||||
}
|
||||
let mut ids = ids.into_iter();
|
||||
let mut fresh = || ids.next().unwrap_or(u32::MAX);
|
||||
sh.charts.iter_mut().for_each(|o| o.id = fresh());
|
||||
sh.images.iter_mut().for_each(|o| o.id = fresh());
|
||||
sh.shapes.iter_mut().for_each(|o| o.id = fresh());
|
||||
sh.slicers.iter_mut().for_each(|o| o.id = fresh());
|
||||
let at = to.min(n);
|
||||
// The copy gets its own copies of the sheet's names, referring to the copy.
|
||||
let copies: Vec<gridcraft_model::DefinedName> = cx
|
||||
|
||||
@@ -36,20 +36,25 @@ use gridcraft_core::split_sheet;
|
||||
mod evolutionary;
|
||||
mod lp;
|
||||
mod model;
|
||||
mod model_range;
|
||||
mod nlp;
|
||||
mod problem;
|
||||
mod reports;
|
||||
mod sensitivity;
|
||||
|
||||
use evolutionary::{EvoEnd, evolutionary};
|
||||
use lp::{Outcome, Sense, branch_and_bound, linearize, solve_lp};
|
||||
pub use model::saved_model;
|
||||
use lp::{Outcome, SimplexProgram, branch_and_bound, simplex_program, solve_lp};
|
||||
use model::{Goal, Method, Model, model_params, parse_model, save_model};
|
||||
pub use model::{saved_model, sensitivity_applies, sensitivity_status};
|
||||
use model_range::{load_model_cells, reset_model, save_model_cells};
|
||||
use nlp::{nlp, project};
|
||||
use problem::{Evaluator, as_num, build_problem};
|
||||
use reports::{ReportInput, insert_report_selections, prepare_reports, report, report_kinds, write_reports};
|
||||
use reports::{NotOptimal, ReportInput, insert_report_selections, prepare_reports, report, report_kinds, write_reports};
|
||||
|
||||
const CMD: &str = "data.solver";
|
||||
const REPORT_CMD: &str = "data.solverReport";
|
||||
const SAVE_CMD: &str = "data.solverSaveModel";
|
||||
const LOAD_CMD: &str = "data.solverLoadModel";
|
||||
/// Excel's limit on decision variables.
|
||||
const MAX_VARS: usize = 200;
|
||||
/// Most scalar constraints (after expanding ranges).
|
||||
@@ -74,7 +79,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Solver…",
|
||||
["Data", "Analysis"],
|
||||
None,
|
||||
"{objective: \"B10\" (formula cell), goal?: max|min|value (default max), value?: number (for goal value), variables: \"B2:B4,D1\" (≤200 constant cells, same sheet), constraints?: [\"B5 <= 10\", \"B2:B4 >= C2:C4\", \"B2 int\", \"B3 bin\", {lhs: \"B6\", op: \"=\", rhs: 3 | \"C6\"}], nonNegative?: bool (default true: variables without a lower bound get 0), method?: grg|simplex|evolutionary (default grg), populationSize?: 100, mutationRate?: 0.075, randomSeed?: 0 (a fixed seed, so runs repeat), convergence?: 0.0001, generationsWithoutImprovement?: 50, requireBounds?: bool (default true: every variable needs both bounds; false searches within ±100 of the start) (Evolutionary options), keep?: bool (default true; false restores the original values), saveModel?: bool (default true: hidden solver_* names), maxEvaluations?: 20000, integerOptimality?: percent (default 0), saved?: bool (solve the sheet's saved model; other params override), reports?: [answer|limits] (written on new sheets when a solution is found)} → {status: optimal|converged|cannotImprove|iterationLimit|infeasible|unbounded|notLinear, message, objective, values: {cell: value}, original: {cell: value}, model: the model as params, evaluations, changed, reports?: [sheet names]}. No `objective` → {model: the saved model} (the menu opens the dialog).",
|
||||
"{objective: \"B10\" (formula cell), goal?: max|min|value (default max), value?: number (for goal value), variables: \"B2:B4,D1\" (≤200 constant cells, same sheet), constraints?: [\"B5 <= 10\", \"B2:B4 >= C2:C4\", \"B2 int\", \"B3 bin\", {lhs: \"B6\", op: \"=\", rhs: 3 | \"C6\"}], nonNegative?: bool (default true: variables without a lower bound get 0), method?: grg|simplex|evolutionary (default grg), populationSize?: 100, mutationRate?: 0.075, randomSeed?: 0 (a fixed seed, so runs repeat), convergence?: 0.0001, generationsWithoutImprovement?: 50, requireBounds?: bool (default true: every variable needs both bounds; false searches within ±100 of the start) (Evolutionary options), keep?: bool (default true; false restores the original values), saveModel?: bool (default true: hidden names, solver_* as Excel names them, with the options; the evaluation limit and generations without improvement, which Excel lacks, as gridcraft_solver_evals and gridcraft_solver_stall), maxEvaluations?: 20000 (at most 200000), integerOptimality?: percent (default 0), saved?: bool (solve the sheet's saved model; other params override), reports?: [answer|sensitivity|limits] (written on new sheets when a solution is found)} → {status: optimal|converged|cannotImprove|iterationLimit|infeasible|unbounded|notLinear, message, objective, values: {cell: value}, original: {cell: value}, model: the model as params, evaluations, changed, reports?: [sheet names], skippedReports?: [{report: \"sensitivity\", reason}] (a Sensitivity report left out because the run did not reach an optimum or the values are not one; the solve and the other reports still apply)}. No `objective` → {model: the saved model} (the menu opens the dialog).",
|
||||
has_doc,
|
||||
run
|
||||
),
|
||||
@@ -83,10 +88,37 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Solver Reports",
|
||||
[],
|
||||
None,
|
||||
"{reports: [answer|limits], values?: {cell: number} (the solution; default the variable cells' current values), original?: {cell: number} (the values before solving; default `values`), message?: text (the result line), model?: data.solver params (default the sheet's saved model, with any data.solver params given here over it)} → {sheets: [names]}. Writes Answer and Limits reports on new sheets before the model's sheet; Limits needs Simplex LP or GRG Nonlinear and no integer constraints. data.solver's result can be passed as is.",
|
||||
"{reports: [answer|sensitivity|limits], values?: {cell: number} (the solution; default the variable cells' current values), original?: {cell: number} (the values before solving; default `values`), message?: text (the result line), skipNotOptimal?: bool (default false: a Sensitivity report for values that are not optimal refuses the call; true leaves it out and writes the others), model?: data.solver params (default the sheet's saved model, with any data.solver params given here over it)} → {sheets: [names], skippedReports?: [{report, reason}]}. Writes Answer, Sensitivity and Limits reports on new sheets before the model's sheet; Sensitivity and Limits need Simplex LP or GRG Nonlinear and no integer constraints, and Sensitivity a Max or Min objective. data.solver's result can be passed as is.",
|
||||
has_doc,
|
||||
report
|
||||
),
|
||||
cmd!(
|
||||
"data.solverSaveModel",
|
||||
"Save Model",
|
||||
[],
|
||||
None,
|
||||
"{to: \"H1\" (the first cell, on the model's sheet), model?: data.solver params (default the sheet's saved model), overwrite?: false} → {range}. Writes the model down a column as formulas: =MAX(objective), =MIN(objective) or =objective=value; =COUNT(variables); one =AND(relation) per constraint (=AND(x=INT(x)) for integer, =AND((x=0)+(x=1)) for binary), which shows TRUE while it holds; and the options as an array constant {method (1 GRG Nonlinear, 2 Simplex LP, 3 Evolutionary), non-negative (1 yes, 2 no), population size, mutation rate, random seed, require bounds (1 yes, 2 no), convergence, generations without improvement, maximum evaluations, integer optimality %}, which spills into the cells to its right. Refused, with the sheet unchanged, when the model's cells, content, another formula's spilled values, merged cells or a table are in the way of the options' spill, or it would run off the sheet, and when the column would cover another formula's spilled values. A column that holds a value or a formula (a model saved there before included) is replaced only with overwrite: true; without it the command is refused (\"already holds data\"), as Excel asks first. One undo step.",
|
||||
has_doc,
|
||||
save_model_cells
|
||||
),
|
||||
cmd!(
|
||||
"data.solverLoadModel",
|
||||
"Load Model",
|
||||
[],
|
||||
None,
|
||||
"{from: \"H1:H6\" (a range on the active sheet; one cell reads down to the first cell without a formula; a range needs a formula in every cell up to its last formula, and may end in empty cells)} → {model}. Reads a model written by Save Model and makes it the sheet's Solver model. The model must end with its options row ({…}), which Save Model writes last; a model cut short (a cell before the options row without a formula, or the options row cleared) is refused and the sheet's model stays as it was.",
|
||||
has_doc,
|
||||
load_model_cells
|
||||
),
|
||||
cmd!(
|
||||
"data.solverReset",
|
||||
"Reset All",
|
||||
[],
|
||||
None,
|
||||
"{} → {removed, masked}. Removes the active sheet's Solver model. A model read from workbook-level names stays for the other sheets and is masked on this one by an empty sheet-level solver_opt (masked: true).",
|
||||
has_doc,
|
||||
reset_model
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -115,24 +147,13 @@ fn optimise(ev: &mut Evaluator, m: &Model, goal: Goal, x0: &[f64], bounds: Bound
|
||||
Outcome::Infeasible
|
||||
} else {
|
||||
match m.method {
|
||||
Method::Simplex => match linearize(ev, x0) {
|
||||
Method::Simplex => match simplex_program(ev, goal, x0) {
|
||||
Err("nonlinear") | Err("error") => {
|
||||
not_linear = true;
|
||||
Outcome::Infeasible
|
||||
}
|
||||
Err(_) => Outcome::Limit,
|
||||
Ok(((cf, f_const), lin)) => {
|
||||
let mut rows: Vec<(Vec<f64>, Sense, f64)> = lin.iter().zip(&ev.rows).map(|((a, k), r)| (a.clone(), r.sense, -k)).collect();
|
||||
let c: Vec<f64> = match goal {
|
||||
Goal::Max => cf.iter().map(|v| -v).collect(),
|
||||
Goal::Min => cf.clone(),
|
||||
Goal::Value(v) => {
|
||||
rows.push((cf.clone(), Sense::Eq, v - f_const));
|
||||
vec![0.0; cf.len()]
|
||||
}
|
||||
};
|
||||
let size = (rows.len() as u64 + 2 * x0.len() as u64 + 1) * (4 * x0.len() as u64 + 2 * rows.len() as u64 + 1);
|
||||
let mut work = LP_WORK.max(size);
|
||||
Ok(SimplexProgram { c, rows, mut work, .. }) => {
|
||||
let mut relax = |l: &[f64], h: &[f64]| solve_lp(&c, &rows, l, h, &mut work);
|
||||
if any_int { branch_and_bound(lo, hi, integer, MAX_NODES_LP, gap, &mut relax).0 } else { relax(lo, hi) }
|
||||
}
|
||||
@@ -182,16 +203,19 @@ fn run(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
return Ok(json!({"model": model}));
|
||||
}
|
||||
let model = parse_model(s, ¶ms)?;
|
||||
let budget = params
|
||||
.get("maxEvaluations")
|
||||
.and_then(Json::as_f64)
|
||||
.filter(|v| v.is_finite() && *v >= 1.0)
|
||||
.map_or(DEFAULT_EVALS, |v| (v as u64).min(MAX_EVALS));
|
||||
let gap = params.get("integerOptimality").and_then(Json::as_f64).filter(|v| v.is_finite()).map_or(0.0, |v| v.clamp(0.0, 100.0) / 100.0);
|
||||
let (budget, gap) = (model.max_evals, model.gap);
|
||||
let keep = bool_param(¶ms, "keep").unwrap_or(true);
|
||||
let save = bool_param(¶ms, "saveModel").unwrap_or(true);
|
||||
let reports = report_kinds(CMD, ¶ms)?;
|
||||
let prob = build_problem(&model);
|
||||
// A Sensitivity report the model has none of (integer, Evolutionary, Value Of) fails the
|
||||
// call, so it is refused before the solve runs rather than after it.
|
||||
if let Some(kinds) = &reports
|
||||
&& kinds.contains(&reports::ReportKind::Sensitivity)
|
||||
&& let Some(why) = sensitivity::sensitivity_refusal(&model, &prob)
|
||||
{
|
||||
return Err(EngineError::Other(why.into()));
|
||||
}
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.sheet(model.sheet).ok_or(EngineError::NoDocument)?;
|
||||
let x0: Vec<f64> = model.vars.iter().map(|c| as_num(&sh.value(*c))).map(|v| if v.is_finite() { v } else { 0.0 }).collect();
|
||||
@@ -251,12 +275,14 @@ fn run(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
let original: serde_json::Map<String, Json> = model.vars.iter().zip(&x0).map(|(c, v)| (c.a1(), json!(v))).collect();
|
||||
let changed = keep && solution.is_some();
|
||||
let built = match (&reports, &solution) {
|
||||
// A Sensitivity report the solution doesn't support is left out (with its reason), so the
|
||||
// solve and the other reports still apply.
|
||||
let (built, skipped) = match (&reports, &solution) {
|
||||
(Some(kinds), Some(x)) => {
|
||||
let inp = ReportInput { model: &model, prob: &prob, original: x0, solution: x.clone(), message: message.to_string(), budget };
|
||||
prepare_reports(s.doc()?, kinds, &inp)?
|
||||
prepare_reports(s.doc()?, kinds, &inp, NotOptimal::Skip { optimum: sensitivity_status(status) })?
|
||||
}
|
||||
_ => Vec::new(),
|
||||
_ => (Vec::new(), Vec::new()),
|
||||
};
|
||||
let count = built.len();
|
||||
let before = s.doc()?.wb.clone();
|
||||
@@ -277,7 +303,7 @@ fn run(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
for (c, v) in model.vars.iter().zip(x) {
|
||||
let style = sh.style_id(*c);
|
||||
sh.set_cell(*c, Cell { value: Value::number(*v), formula: None, style });
|
||||
sh.set_cell(*c, Cell { value: Value::number(*v), formula: None, style, py: None });
|
||||
}
|
||||
for c in &model.vars {
|
||||
cx.touch(sheet + count, *c);
|
||||
@@ -299,6 +325,9 @@ fn run(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if reports.is_some() && solution.is_some() {
|
||||
out["reports"] = json!(names);
|
||||
}
|
||||
if !skipped.is_empty() {
|
||||
out["skippedReports"] = json!(skipped);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
use super::model::Goal;
|
||||
use super::problem::Evaluator;
|
||||
use super::{CellRef, EPS};
|
||||
use super::{CellRef, EPS, LP_WORK};
|
||||
|
||||
// ---------------------------------------------------------------- evaluation
|
||||
|
||||
type BoundRow = (Vec<(usize, f64)>, Sense, f64);
|
||||
|
||||
/// A row of an LP: coefficients, sense and right side.
|
||||
pub(super) type LpRow = (Vec<f64>, Sense, f64);
|
||||
|
||||
/// Objective (gradient, constant) and per-row (gradient, constant).
|
||||
pub(super) type Linearized = ((Vec<f64>, f64), Vec<(Vec<f64>, f64)>);
|
||||
|
||||
@@ -34,9 +38,10 @@ pub(super) enum Outcome {
|
||||
Limit,
|
||||
}
|
||||
|
||||
struct Map {
|
||||
var: usize,
|
||||
sign: f64,
|
||||
/// A column of the simplex tableau standing for a variable: `x_var = offset + Σ sign·y`.
|
||||
pub(super) struct Map {
|
||||
pub(super) var: usize,
|
||||
pub(super) sign: f64,
|
||||
}
|
||||
|
||||
struct VariableMap {
|
||||
@@ -45,6 +50,23 @@ struct VariableMap {
|
||||
bound_rows: Vec<BoundRow>,
|
||||
}
|
||||
|
||||
/// The final tableau of an optimal LP, for the Sensitivity report.
|
||||
pub(super) struct FinalTableau {
|
||||
/// Rows `B⁻¹A | B⁻¹b`.
|
||||
pub(super) t: Vec<Vec<f64>>,
|
||||
pub(super) basis: Vec<usize>,
|
||||
/// The phase-2 reduced costs.
|
||||
pub(super) obj: Vec<f64>,
|
||||
pub(super) width: usize,
|
||||
/// The first artificial column.
|
||||
pub(super) first_art: usize,
|
||||
pub(super) cols: Vec<Map>,
|
||||
/// Per input row: the column that started as its unit column, and whether the row was
|
||||
/// negated to make its right side non-negative.
|
||||
pub(super) ident: Vec<usize>,
|
||||
pub(super) flipped: Vec<bool>,
|
||||
}
|
||||
|
||||
struct Tableau {
|
||||
rows: Vec<Vec<f64>>,
|
||||
basis: Vec<usize>,
|
||||
@@ -82,7 +104,9 @@ fn map_variables(n: usize, lo: &[f64], hi: &[f64]) -> VariableMap {
|
||||
VariableMap { columns, offset, bound_rows }
|
||||
}
|
||||
|
||||
fn normalize_rows(rows: &[(Vec<f64>, Sense, f64)], bound_rows: Vec<BoundRow>, columns: &[Map], offset: &[f64]) -> Vec<(Vec<f64>, Sense, f64)> {
|
||||
/// The rows over the tableau's columns, each with a non-negative right side, and per row
|
||||
/// whether it was negated to get there.
|
||||
fn normalize_rows(rows: &[(Vec<f64>, Sense, f64)], bound_rows: Vec<BoundRow>, columns: &[Map], offset: &[f64]) -> (Vec<LpRow>, Vec<bool>) {
|
||||
let width = columns.len();
|
||||
let mut transformed = Vec::with_capacity(rows.len() + bound_rows.len());
|
||||
for (coefficients, sense, rhs) in rows {
|
||||
@@ -105,7 +129,8 @@ fn normalize_rows(rows: &[(Vec<f64>, Sense, f64)], bound_rows: Vec<BoundRow>, co
|
||||
transformed.push((row, sense, rhs));
|
||||
}
|
||||
// Rows with a negative right side are negated so every right side is >= 0.
|
||||
transformed
|
||||
let flipped = transformed.iter().map(|row| row.2 < 0.0).collect();
|
||||
let normalized = transformed
|
||||
.into_iter()
|
||||
.map(|(row, sense, rhs)| {
|
||||
if rhs < 0.0 {
|
||||
@@ -119,7 +144,8 @@ fn normalize_rows(rows: &[(Vec<f64>, Sense, f64)], bound_rows: Vec<BoundRow>, co
|
||||
(row, sense, rhs)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
(normalized, flipped)
|
||||
}
|
||||
|
||||
fn build_tableau(normalized: &[(Vec<f64>, Sense, f64)], decision_cols: usize, slack_cols: usize, artificial_cols: usize) -> Tableau {
|
||||
@@ -388,20 +414,33 @@ fn simplex(t: &mut [Vec<f64>], basis: &mut [usize], obj: &mut [f64], allowed: &[
|
||||
|
||||
/// Minimises `c·x` subject to `rows` (coefficients, sense, rhs) and `lo ≤ x ≤ hi`.
|
||||
pub(super) fn solve_lp(c: &[f64], rows: &[(Vec<f64>, Sense, f64)], lo: &[f64], hi: &[f64], budget: &mut u64) -> Outcome {
|
||||
solve_lp_tableau(c, rows, lo, hi, budget).0
|
||||
}
|
||||
|
||||
/// [`solve_lp`], with the final tableau when it is optimal.
|
||||
pub(super) fn solve_lp_tableau(
|
||||
c: &[f64],
|
||||
rows: &[(Vec<f64>, Sense, f64)],
|
||||
lo: &[f64],
|
||||
hi: &[f64],
|
||||
budget: &mut u64,
|
||||
) -> (Outcome, Option<FinalTableau>) {
|
||||
if lo.iter().zip(hi).any(|(l, h)| l > h) {
|
||||
return Outcome::Infeasible;
|
||||
return (Outcome::Infeasible, None);
|
||||
}
|
||||
// x_j = offset + Σ sign·y_k with y ≥ 0.
|
||||
let mapping = map_variables(c.len(), lo, hi);
|
||||
let VariableMap { columns: cols, offset, bound_rows: extra } = mapping;
|
||||
let ny = cols.len();
|
||||
// Rows over y: (coefficients, sense, rhs).
|
||||
let normalized = normalize_rows(rows, extra, &cols, &offset);
|
||||
let (normalized, flipped) = normalize_rows(rows, extra, &cols, &offset);
|
||||
let n_slack = normalized.iter().filter(|row| row.1 != Sense::Eq).count();
|
||||
let n_art = normalized.iter().filter(|row| row.1 != Sense::Le).count();
|
||||
let mut tableau = build_tableau(&normalized, ny, n_slack, n_art);
|
||||
// Each row's starting basic column is its unit column.
|
||||
let ident = tableau.basis.clone();
|
||||
if let Err(outcome) = phase_one(&mut tableau, &normalized, budget) {
|
||||
return outcome;
|
||||
return (outcome, None);
|
||||
}
|
||||
let Tableau { rows: mut t, mut basis, width, decision_cols: ny, slack_cols: n_slack, .. } = tableau;
|
||||
let is_art = |column: usize| column >= ny + n_slack && column < width;
|
||||
@@ -423,8 +462,8 @@ pub(super) fn solve_lp(c: &[f64], rows: &[(Vec<f64>, Sense, f64)], lo: &[f64], h
|
||||
}
|
||||
let allowed: Vec<bool> = (0..width).map(|j| !is_art(j)).collect();
|
||||
match simplex(&mut t, &mut basis, &mut obj, &allowed, budget) {
|
||||
Step::Limit => return Outcome::Limit,
|
||||
Step::Unbounded => return Outcome::Unbounded,
|
||||
Step::Limit => return (Outcome::Limit, None),
|
||||
Step::Unbounded => return (Outcome::Unbounded, None),
|
||||
Step::Optimal => {}
|
||||
}
|
||||
let mut y = vec![0.0; width];
|
||||
@@ -442,7 +481,43 @@ pub(super) fn solve_lp(c: &[f64], rows: &[(Vec<f64>, Sense, f64)], lo: &[f64], h
|
||||
}
|
||||
}
|
||||
let val: f64 = c.iter().zip(&x).map(|(a, b)| a * b).sum();
|
||||
Outcome::Solved(x, val, true)
|
||||
let tableau = FinalTableau { t, basis, obj, width, first_art: ny + n_slack, cols, ident, flipped };
|
||||
(Outcome::Solved(x, val, true), Some(tableau))
|
||||
}
|
||||
|
||||
/// The LP to minimise for `goal`, from the linearised model: the costs, and the rows
|
||||
/// (coefficients, sense, right side) in the order of `model_rows`.
|
||||
pub(super) fn linear_program(goal: Goal, cf: &[f64], f_const: f64, lin: &[(Vec<f64>, f64)], model_rows: &[Row]) -> (Vec<f64>, Vec<LpRow>) {
|
||||
let mut rows: Vec<LpRow> = lin.iter().zip(model_rows).map(|((a, k), r)| (a.clone(), r.sense, -k)).collect();
|
||||
let c = match goal {
|
||||
Goal::Max => cf.iter().map(|v| -v).collect(),
|
||||
Goal::Min => cf.to_vec(),
|
||||
Goal::Value(v) => {
|
||||
rows.push((cf.to_vec(), Sense::Eq, v - f_const));
|
||||
vec![0.0; cf.len()]
|
||||
}
|
||||
};
|
||||
(c, rows)
|
||||
}
|
||||
|
||||
/// The linear program Simplex LP solves: the model linearised at `x0`, its objective
|
||||
/// coefficients, the costs and rows for the goal, and the work limit for its size.
|
||||
pub(super) struct SimplexProgram {
|
||||
pub(super) cf: Vec<f64>,
|
||||
pub(super) c: Vec<f64>,
|
||||
pub(super) rows: Vec<LpRow>,
|
||||
pub(super) work: u64,
|
||||
}
|
||||
|
||||
/// Builds the [`SimplexProgram`] at `x0`. The solve and the Sensitivity report both build it
|
||||
/// here from the start values, so the report's solve repeats the solve's pivots and returns the
|
||||
/// same vertex: linearising at another point changes the coefficients in their last bits, and
|
||||
/// that can break a tie between pivots the other way.
|
||||
pub(super) fn simplex_program(ev: &mut Evaluator, goal: Goal, x0: &[f64]) -> std::result::Result<SimplexProgram, &'static str> {
|
||||
let ((cf, f_const), lin) = linearize(ev, x0)?;
|
||||
let (c, rows) = linear_program(goal, &cf, f_const, &lin, &ev.rows);
|
||||
let size = (rows.len() as u64 + 2 * x0.len() as u64 + 1) * (4 * x0.len() as u64 + 2 * rows.len() as u64 + 1);
|
||||
Ok(SimplexProgram { cf, c, rows, work: LP_WORK.max(size) })
|
||||
}
|
||||
|
||||
/// Linearises the model at `x0` (n + 1 evaluations) and checks it at one more point. Returns the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
CMD, CellRef, DefinedName, EngineError, Json, MAX_CONSTRAINTS, MAX_POPULATION, MAX_VARS, MIN_POPULATION, RangeRef, Result, Session, Workbook,
|
||||
bad, bool_param, f64_param, json, split_sheet, str_param,
|
||||
CMD, CellRef, DEFAULT_EVALS, DefinedName, EngineError, Json, MAX_CONSTRAINTS, MAX_EVALS, MAX_POPULATION, MAX_VARS, MIN_POPULATION, RangeRef,
|
||||
Result, Session, Workbook, bad, bool_param, f64_param, json, split_sheet, str_param,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- model
|
||||
@@ -128,6 +128,10 @@ pub(super) struct Model {
|
||||
pub(super) non_negative: bool,
|
||||
pub(super) method: Method,
|
||||
pub(super) evo: EvoOptions,
|
||||
/// The evaluation limit (in place of Excel's iterations and time limits).
|
||||
pub(super) max_evals: u64,
|
||||
/// Integer Optimality, as a fraction.
|
||||
pub(super) gap: f64,
|
||||
}
|
||||
|
||||
/// Cells of `"B2:B4,D1"` on one sheet (row-major per area, in order), at most `cap`.
|
||||
@@ -259,7 +263,8 @@ pub(super) fn parse_model(s: &Session, p: &Json) -> Result<Model> {
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
vars.retain(|c| seen.insert(*c));
|
||||
if vars.iter().any(|c| sh.cell(*c).is_some_and(|c| c.formula.is_some())) {
|
||||
// Python cells count as formulas: a solution written over one would lose its code.
|
||||
if vars.iter().any(|c| sh.cell(*c).is_some_and(|c| c.formula.is_some() || c.py.is_some())) {
|
||||
return Err(EngineError::Other("Variable cells must hold constants, not formulas.".into()));
|
||||
}
|
||||
let mut cons = Vec::new();
|
||||
@@ -288,13 +293,50 @@ pub(super) fn parse_model(s: &Session, p: &Json) -> Result<Model> {
|
||||
"evolutionary" | "evo" | "genetic" => Method::Evolutionary,
|
||||
m => return Err(bad(CMD, format!("unknown method `{m}` (grg, simplex or evolutionary)"))),
|
||||
};
|
||||
Ok(Model { sheet, objective, goal, vars, vars_text, cons, non_negative, method, evo: evo_options(p) })
|
||||
let num = |k: &str| p.get(k).and_then(Json::as_f64).or_else(|| str_param(p, k).and_then(num_text)).filter(|v| v.is_finite());
|
||||
let max_evals = num("maxEvaluations").filter(|v| *v >= 1.0).map_or(DEFAULT_EVALS, |v| (v as u64).min(MAX_EVALS));
|
||||
// Integer Optimality is in percent: "5" and "5%" both mean 5 % (number parsing would read
|
||||
// "5%" as 0.05).
|
||||
let percent = p
|
||||
.get("integerOptimality")
|
||||
.and_then(Json::as_f64)
|
||||
.or_else(|| str_param(p, "integerOptimality").and_then(|t| num_text(t.trim().trim_end_matches('%'))))
|
||||
.filter(|v| v.is_finite());
|
||||
let gap = percent.map_or(0.0, |v| v.clamp(0.0, 100.0) / 100.0);
|
||||
Ok(Model { sheet, objective, goal, vars, vars_text, cons, non_negative, method, evo: evo_options(p), max_evals, gap })
|
||||
}
|
||||
|
||||
/// Whether a `data.solver` result status is a solution the Sensitivity report can describe: an
|
||||
/// optimum (Simplex LP) or a converged local optimum (GRG Nonlinear). A run that stopped at its
|
||||
/// evaluation limit, or an Evolutionary run, has none.
|
||||
pub fn sensitivity_status(status: &str) -> bool {
|
||||
matches!(status, "optimal" | "converged")
|
||||
}
|
||||
|
||||
/// Whether `data.solver` params (such as the `model` of its result) describe a model that can
|
||||
/// have a Sensitivity report: Simplex LP or GRG Nonlinear, a Max or Min objective and no
|
||||
/// integer or binary constraints.
|
||||
pub fn sensitivity_applies(p: &Json) -> bool {
|
||||
let method = str_param(p, "method").unwrap_or("grg").to_ascii_lowercase().replace([' ', '_', '-'], "");
|
||||
let goal = str_param(p, "goal").or_else(|| str_param(p, "to")).unwrap_or("max").to_ascii_lowercase();
|
||||
let integer = p.get("constraints").and_then(Json::as_array).is_some_and(|list| {
|
||||
list.iter().any(|c| {
|
||||
let rel = match c {
|
||||
Json::String(t) => split_constraint(t).map(|(_, rel, _)| rel),
|
||||
Json::Object(_) => str_param(c, "op").or_else(|| str_param(c, "relation")).and_then(Rel::parse),
|
||||
_ => None,
|
||||
};
|
||||
matches!(rel, Some(Rel::Int | Rel::Bin))
|
||||
})
|
||||
});
|
||||
!integer && !matches!(method.as_str(), "evolutionary" | "evo" | "genetic") && !matches!(goal.as_str(), "value" | "valueof" | "value of")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- saved model
|
||||
|
||||
fn ref_text(sheet_name: &str, cells: &str) -> String {
|
||||
let q = crate::pivot::quote_sheet(sheet_name);
|
||||
/// `"B2:B4,D1"` as absolute references, each qualified by `sheet_name` when given.
|
||||
fn abs_text(sheet_name: Option<&str>, cells: &str) -> String {
|
||||
let q = sheet_name.map(|n| format!("{}!", crate::pivot::quote_sheet(n))).unwrap_or_default();
|
||||
cells
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
@@ -302,16 +344,49 @@ fn ref_text(sheet_name: &str, cells: &str) -> String {
|
||||
.filter_map(|part| {
|
||||
let r = RangeRef::parse(split_sheet(part).1.trim())?;
|
||||
let abs = |c: CellRef| format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row as u64 + 1);
|
||||
Some(if r.is_single() { format!("{q}!{}", abs(r.start)) } else { format!("{q}!{}:{}", abs(r.start), abs(r.end)) })
|
||||
Some(if r.is_single() { format!("{q}{}", abs(r.start)) } else { format!("{q}{}:{}", abs(r.start), abs(r.end)) })
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
}
|
||||
|
||||
fn ref_text(sheet_name: &str, cells: &str) -> String {
|
||||
abs_text(Some(sheet_name), cells)
|
||||
}
|
||||
|
||||
/// The evaluation limit and the generations without improvement have no Excel equivalent:
|
||||
/// Excel's `solver_itr` counts iterations (100 by default) and `solver_mni` seconds, so a
|
||||
/// workbook from Excel would cut GridCraft's search short. They are saved under GridCraft's own
|
||||
/// names, which Excel ignores.
|
||||
const EVALS_NAME: &str = "gridcraft_solver_evals";
|
||||
const STALL_NAME: &str = "gridcraft_solver_stall";
|
||||
|
||||
/// The names of a saved model after `solver_`, as Excel's Solver writes them (GridCraft writes
|
||||
/// some of them). Saving a model and Reset All remove these and no other `solver_` names.
|
||||
const SOLVER_KEYS: [&str; 28] = [
|
||||
"adj", "cvg", "drv", "eng", "est", "itr", "lin", "mip", "mni", "mrt", "msl", "neg", "nod", "num", "nwt", "opt", "pre", "rbv", "rlx", "rsd",
|
||||
"scl", "sho", "ssz", "tim", "tol", "typ", "val", "ver",
|
||||
];
|
||||
|
||||
/// Whether `name` is one of the names of a saved model: Excel's Solver names (`solver_opt`,
|
||||
/// `solver_lhs1` and so on) and GridCraft's own. A user's name such as `solver_budget` is not.
|
||||
pub(super) fn is_model_name(name: &str) -> bool {
|
||||
let name = name.to_ascii_lowercase();
|
||||
if name == EVALS_NAME || name == STALL_NAME {
|
||||
return true;
|
||||
}
|
||||
let Some(key) = name.strip_prefix("solver_") else { return false };
|
||||
if SOLVER_KEYS.contains(&key) {
|
||||
return true;
|
||||
}
|
||||
// One name per constraint: solver_lhs1, solver_rel1, solver_rhs1, …
|
||||
["lhs", "rel", "rhs"].iter().filter_map(|p| key.strip_prefix(p)).any(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
|
||||
}
|
||||
|
||||
/// The model as hidden names scoped to its sheet (replacing any saved before).
|
||||
pub(super) fn save_model(wb: &mut Workbook, m: &Model) {
|
||||
let Some(sheet_name) = wb.sheet(m.sheet).map(|s| s.name.clone()) else { return };
|
||||
wb.names.retain(|n| !(n.scope == Some(m.sheet) && n.name.to_ascii_lowercase().starts_with("solver_")));
|
||||
wb.names.retain(|n| !(n.scope == Some(m.sheet) && is_model_name(&n.name)));
|
||||
let mut put = |name: String, formula: String| {
|
||||
wb.names.push(DefinedName { name, scope: Some(m.sheet), formula, comment: String::new(), hidden: true });
|
||||
};
|
||||
@@ -349,26 +424,31 @@ pub(super) fn save_model(wb: &mut Workbook, m: &Model) {
|
||||
Method::Evolutionary => "3",
|
||||
};
|
||||
put("solver_eng".into(), eng.into());
|
||||
if m.method == Method::Evolutionary {
|
||||
put("solver_ssz".into(), m.evo.population.to_string());
|
||||
put("solver_mrt".into(), m.evo.mutation.to_string());
|
||||
put("solver_rsd".into(), m.evo.seed.to_string());
|
||||
put("solver_rbv".into(), if m.evo.require_bounds { "1" } else { "2" }.into());
|
||||
}
|
||||
put(EVALS_NAME.into(), m.max_evals.to_string());
|
||||
put("solver_tol".into(), m.gap.to_string());
|
||||
// The Evolutionary options are kept whatever the method, as Solver Options sets them, so
|
||||
// switching methods doesn't lose them.
|
||||
put("solver_ssz".into(), m.evo.population.to_string());
|
||||
put("solver_mrt".into(), m.evo.mutation.to_string());
|
||||
put("solver_rsd".into(), m.evo.seed.to_string());
|
||||
put("solver_rbv".into(), if m.evo.require_bounds { "1" } else { "2" }.into());
|
||||
put("solver_cvg".into(), m.evo.convergence.to_string());
|
||||
put(STALL_NAME.into(), m.evo.stall.to_string());
|
||||
put("solver_ver".into(), "3".into());
|
||||
}
|
||||
|
||||
/// The saved model of a sheet as `data.solver` params, if any.
|
||||
///
|
||||
/// A sheet's own (sheet-level) model wins over one saved as workbook-level names, and its names
|
||||
/// are read alone, so the two never mix. A sheet-level `solver_opt` of `""` (written by Reset
|
||||
/// All over a workbook-level model) means the sheet has no model.
|
||||
pub fn saved_model(wb: &Workbook, sheet: usize) -> Option<Json> {
|
||||
let get = |n: &str| {
|
||||
wb.names
|
||||
.iter()
|
||||
.find(|d| d.name.eq_ignore_ascii_case(n) && (d.scope == Some(sheet) || d.scope.is_none()))
|
||||
.map(|d| d.formula.trim().trim_start_matches('=').to_string())
|
||||
};
|
||||
let named = |n: &str, scope: Option<usize>| wb.names.iter().find(|d| d.name.eq_ignore_ascii_case(n) && d.scope == scope);
|
||||
let scope = if named("solver_opt", Some(sheet)).is_some() { Some(sheet) } else { None };
|
||||
let get = |n: &str| named(n, scope).map(|d| d.formula.trim().trim_start_matches('=').to_string());
|
||||
let local = |t: String| -> String { t.split(',').map(|p| split_sheet(p.trim()).1.replace('$', "")).collect::<Vec<_>>().join(",") };
|
||||
let objective = local(get("solver_opt")?);
|
||||
if objective.is_empty() {
|
||||
if objective.trim_matches('"').trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let goal = match get("solver_typ").as_deref().map(str::trim) {
|
||||
@@ -407,11 +487,21 @@ pub fn saved_model(wb: &Workbook, sheet: usize) -> Option<Json> {
|
||||
"nonNegative": get("solver_neg").as_deref().map(str::trim) != Some("2"),
|
||||
"method": method,
|
||||
});
|
||||
for (name, key) in [("solver_ssz", "populationSize"), ("solver_mrt", "mutationRate"), ("solver_rsd", "randomSeed")] {
|
||||
for (name, key) in [
|
||||
("solver_ssz", "populationSize"),
|
||||
("solver_mrt", "mutationRate"),
|
||||
("solver_rsd", "randomSeed"),
|
||||
("solver_cvg", "convergence"),
|
||||
(STALL_NAME, "generationsWithoutImprovement"),
|
||||
(EVALS_NAME, "maxEvaluations"),
|
||||
] {
|
||||
if let Some(v) = get(name).and_then(|v| num_text(&v)) {
|
||||
model[key] = json!(v);
|
||||
model[key] = number(v);
|
||||
}
|
||||
}
|
||||
if let Some(v) = get("solver_tol").and_then(|v| num_text(&v)) {
|
||||
model["integerOptimality"] = number(percent(v));
|
||||
}
|
||||
if let Some(v) = get("solver_rbv") {
|
||||
model["requireBounds"] = json!(v.trim() != "2");
|
||||
}
|
||||
@@ -448,15 +538,200 @@ pub(super) fn model_params(wb: &Workbook, m: &Model) -> Json {
|
||||
"constraints": constraints,
|
||||
"nonNegative": m.non_negative,
|
||||
"method": method,
|
||||
"maxEvaluations": m.max_evals,
|
||||
"integerOptimality": number(percent(m.gap)),
|
||||
});
|
||||
if m.method == Method::Evolutionary {
|
||||
let e = &m.evo;
|
||||
out["populationSize"] = json!(e.population);
|
||||
out["mutationRate"] = json!(e.mutation);
|
||||
out["randomSeed"] = json!(e.seed);
|
||||
out["convergence"] = json!(e.convergence);
|
||||
out["generationsWithoutImprovement"] = json!(e.stall);
|
||||
out["requireBounds"] = json!(e.require_bounds);
|
||||
}
|
||||
let e = &m.evo;
|
||||
out["populationSize"] = json!(e.population);
|
||||
out["mutationRate"] = json!(e.mutation);
|
||||
out["randomSeed"] = json!(e.seed);
|
||||
out["convergence"] = json!(e.convergence);
|
||||
out["generationsWithoutImprovement"] = json!(e.stall);
|
||||
out["requireBounds"] = json!(e.require_bounds);
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- model on cells
|
||||
|
||||
/// The cells Save Model writes, top to bottom, as formula text.
|
||||
pub(super) fn model_formulas(m: &Model) -> Result<Vec<String>> {
|
||||
let one_area = |t: &str| !t.contains(',');
|
||||
let mut rows = Vec::with_capacity(m.cons.len() + 3);
|
||||
let obj = abs_text(None, &m.objective.a1());
|
||||
rows.push(match m.goal {
|
||||
Goal::Max => format!("=MAX({obj})"),
|
||||
Goal::Min => format!("=MIN({obj})"),
|
||||
Goal::Value(v) => format!("={obj}={}", gridcraft_core::number_to_text(v)),
|
||||
});
|
||||
rows.push(format!("=COUNT({})", abs_text(None, &m.vars_text)));
|
||||
for c in &m.cons {
|
||||
if !one_area(&c.lhs_text) || !one_area(&c.rhs_text) {
|
||||
return Err(EngineError::Other("Save Model needs each side of a constraint to be one range.".into()));
|
||||
}
|
||||
let lhs = abs_text(None, &c.lhs_text);
|
||||
rows.push(match (c.rel, &c.rhs) {
|
||||
(Rel::Int, _) => format!("=AND({lhs}=INT({lhs}))"),
|
||||
(Rel::Bin, _) => format!("=AND(({lhs}=0)+({lhs}=1))"),
|
||||
(rel, Rhs::Cells(_)) => format!("=AND({lhs}{}{})", rel.text(), abs_text(None, &c.rhs_text)),
|
||||
(rel, Rhs::Const(v)) => format!("=AND({lhs}{}{})", rel.text(), gridcraft_core::number_to_text(*v)),
|
||||
(rel, Rhs::None) => format!("=AND({lhs}{}0)", rel.text()),
|
||||
});
|
||||
}
|
||||
let e = &m.evo;
|
||||
let n = gridcraft_core::number_to_text;
|
||||
let eng = match m.method {
|
||||
Method::Grg => 1,
|
||||
Method::Simplex => 2,
|
||||
Method::Evolutionary => 3,
|
||||
};
|
||||
let yes = |b: bool| if b { 1 } else { 2 };
|
||||
rows.push(format!(
|
||||
"={{{eng},{},{},{},{},{},{},{},{},{}}}",
|
||||
yes(m.non_negative),
|
||||
e.population,
|
||||
n(e.mutation),
|
||||
e.seed,
|
||||
yes(e.require_bounds),
|
||||
n(e.convergence),
|
||||
e.stall,
|
||||
m.max_evals,
|
||||
n(percent(m.gap))
|
||||
));
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// `NAME(inner)` → inner, ignoring case.
|
||||
fn call_args(t: &str, name: &str) -> Option<String> {
|
||||
let head = t.get(..name.len() + 1)?;
|
||||
(head.eq_ignore_ascii_case(&format!("{name}(")) && t.ends_with(')')).then(|| t.get(name.len() + 1..t.len() - 1).map(str::to_string))?
|
||||
}
|
||||
|
||||
/// References without sheet names or `$`.
|
||||
fn local_refs(t: &str) -> String {
|
||||
t.split(',').map(|p| split_sheet(p.trim()).1.replace('$', "")).collect::<Vec<_>>().join(",")
|
||||
}
|
||||
|
||||
fn not_a_model() -> EngineError {
|
||||
EngineError::Other("The range doesn't hold a Solver model saved with Save Model.".into())
|
||||
}
|
||||
|
||||
/// How many values the options row of Save Model holds at least: method, non-negative,
|
||||
/// population size, mutation rate, random seed, require bounds, convergence and generations
|
||||
/// without improvement.
|
||||
const OPTIONS_FIELDS: usize = 8;
|
||||
|
||||
/// The options row of Save Model, `{method, non-negative, …}`, into `model`. Refused when it
|
||||
/// holds fewer than [`OPTIONS_FIELDS`] values or a value that isn't a number: a row cut short
|
||||
/// or edited by hand would otherwise load with default options in place of the saved ones.
|
||||
fn read_options_row(model: &mut Json, list: &str) -> Result<()> {
|
||||
let incomplete = || {
|
||||
EngineError::Other(format!(
|
||||
"The model's options row doesn't hold the {OPTIONS_FIELDS} or more numbers Save Model writes, so the range isn't a complete model saved with Save Model."
|
||||
))
|
||||
};
|
||||
// Every value is checked; only the first ones are kept (the row is one formula, so its
|
||||
// length is bounded by the formula's).
|
||||
let mut v: Vec<f64> = Vec::with_capacity(16);
|
||||
let mut count = 0usize;
|
||||
for t in list.split([',', ';']) {
|
||||
let x = num_text(t).ok_or_else(incomplete)?;
|
||||
count = count.saturating_add(1);
|
||||
if v.len() < 16 {
|
||||
v.push(x);
|
||||
}
|
||||
}
|
||||
if count < OPTIONS_FIELDS {
|
||||
return Err(incomplete());
|
||||
}
|
||||
let get = |k: usize| v.get(k).copied();
|
||||
model["method"] = json!(match get(0).map(|x| x as i64) {
|
||||
Some(2) => "simplex",
|
||||
Some(3) => "evolutionary",
|
||||
_ => "grg",
|
||||
});
|
||||
model["nonNegative"] = json!(get(1) != Some(2.0));
|
||||
for (k, key) in [
|
||||
(2, "populationSize"),
|
||||
(3, "mutationRate"),
|
||||
(4, "randomSeed"),
|
||||
(6, "convergence"),
|
||||
(7, "generationsWithoutImprovement"),
|
||||
(8, "maxEvaluations"),
|
||||
(9, "integerOptimality"),
|
||||
] {
|
||||
if let Some(x) = get(k) {
|
||||
model[key] = json!(x);
|
||||
}
|
||||
}
|
||||
model["requireBounds"] = json!(get(5) != Some(2.0));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One constraint row of Save Model (`AND(relation)`) as a `data.solver` constraint.
|
||||
fn read_constraint_row(t: String) -> Result<String> {
|
||||
let t = call_args(&t, "AND").unwrap_or(t);
|
||||
if let Some((x, y)) = t.split_once("=INT(").or_else(|| t.split_once("=int(")) {
|
||||
if y.strip_suffix(')') != Some(x) {
|
||||
return Err(not_a_model());
|
||||
}
|
||||
return Ok(format!("{} int", local_refs(x)));
|
||||
}
|
||||
if let Some(x) = t.strip_prefix('(').and_then(|x| x.strip_suffix("=1)")).and_then(|x| x.split_once("=0)+(")).filter(|(a, b)| a == b) {
|
||||
return Ok(format!("{} bin", local_refs(x.0)));
|
||||
}
|
||||
let (l, rel, r) = split_constraint(&t).ok_or_else(not_a_model)?;
|
||||
let r = r.trim_matches('"');
|
||||
Ok(match r.to_ascii_lowercase().as_str() {
|
||||
"integer" => format!("{} int", local_refs(&l)),
|
||||
"binary" => format!("{} bin", local_refs(&l)),
|
||||
_ if num_text(r).is_some() => format!("{} {} {r}", local_refs(&l), rel.text()),
|
||||
_ => format!("{} {} {}", local_refs(&l), rel.text(), local_refs(r)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads formula text written by Save Model back into `data.solver` params.
|
||||
pub(super) fn parse_model_formulas(rows: &[String]) -> Result<Json> {
|
||||
let body = |t: &str| t.trim().trim_start_matches('=').replace(' ', "");
|
||||
let first = body(rows.first().ok_or_else(not_a_model)?);
|
||||
let (goal, objective, value) = if let Some(o) = call_args(&first, "MAX") {
|
||||
("max", o, 0.0)
|
||||
} else if let Some(o) = call_args(&first, "MIN") {
|
||||
("min", o, 0.0)
|
||||
} else {
|
||||
let (o, v) = first.split_once('=').ok_or_else(not_a_model)?;
|
||||
("value", o.to_string(), num_text(v).ok_or_else(not_a_model)?)
|
||||
};
|
||||
let variables = call_args(&body(rows.get(1).ok_or_else(not_a_model)?), "COUNT").ok_or_else(not_a_model)?;
|
||||
let mut model = json!({"objective": local_refs(&objective), "goal": goal, "value": value, "variables": local_refs(&variables)});
|
||||
let mut constraints = Vec::new();
|
||||
let rest = rows.get(2..).unwrap_or(&[]);
|
||||
for (i, row) in rest.iter().enumerate() {
|
||||
let t = body(row);
|
||||
if let Some(list) = t.strip_prefix('{').and_then(|x| x.strip_suffix('}')) {
|
||||
// The options: the last row.
|
||||
if i + 1 != rest.len() {
|
||||
return Err(not_a_model());
|
||||
}
|
||||
read_options_row(&mut model, list)?;
|
||||
break;
|
||||
}
|
||||
constraints.push(json!(read_constraint_row(t)?));
|
||||
}
|
||||
model["constraints"] = json!(constraints);
|
||||
Ok(model)
|
||||
}
|
||||
|
||||
/// Integer Optimality as a percent from its stored fraction. The product is rounded to 1E-10 %,
|
||||
/// so a fraction such as 0.07 reads back as 7 and not 7.000000000000001.
|
||||
fn percent(fraction: f64) -> f64 {
|
||||
let p = fraction * 100.0;
|
||||
let rounded = (p * 1e10).round() / 1e10;
|
||||
if rounded.is_finite() { rounded } else { p }
|
||||
}
|
||||
|
||||
/// A number read from a hidden name as JSON: a whole number as an integer, so the Solver dialog
|
||||
/// shows 20000 and not 20000.0.
|
||||
fn number(v: f64) -> Json {
|
||||
// Whole numbers below 2^53 are exact as integers.
|
||||
if v.fract() == 0.0 && v.abs() < 9_007_199_254_740_992.0 { json!(v as i64) } else { json!(v) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
//! Load/Save Model and Reset All: the model written on cells of its sheet, read back from them,
|
||||
//! and removed from the sheet.
|
||||
|
||||
use super::model::{Rhs, is_model_name, model_formulas, parse_model, parse_model_formulas, save_model, saved_model};
|
||||
use super::{
|
||||
Cell, CellRef, DefinedName, EngineError, Json, LOAD_CMD, MAX_CONSTRAINTS, RangeRef, Result, SAVE_CMD, Session, Sheet, Value, bad, bool_param,
|
||||
edit, json, split_sheet, str_param,
|
||||
};
|
||||
use crate::cmd::analysis::OVERWRITE_ERROR;
|
||||
use crate::cmd::cell_param_on;
|
||||
|
||||
/// Most cells a model written by Save Model takes: the objective, the variables, the options
|
||||
/// and one per constraint.
|
||||
const MAX_MODEL_ROWS: usize = MAX_CONSTRAINTS + 3;
|
||||
|
||||
fn is_options_row(formula: &str) -> bool {
|
||||
formula.trim().trim_start_matches('=').trim_start().starts_with('{')
|
||||
}
|
||||
|
||||
/// The formulas from `start` down: to the first cell without a formula, or through the options
|
||||
/// row (`{…}`) that ends a model written by Save Model; at most [`MAX_MODEL_ROWS`] cells.
|
||||
fn formulas_down(sheet: &Sheet, start: CellRef) -> Vec<(CellRef, String)> {
|
||||
let mut out = Vec::new();
|
||||
for k in 0..MAX_MODEL_ROWS as u32 {
|
||||
let Some(c) = start.row.checked_add(k).map(|row| CellRef::new(row, start.col)) else { break };
|
||||
let Some(f) = sheet.cell(c).and_then(|x| x.formula.as_ref()).map(|f| f.text.clone()) else { break };
|
||||
let last = is_options_row(&f);
|
||||
out.push((c, f));
|
||||
if last {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The cells of a model written by Save Model that starts at `start` (none if the cells there
|
||||
/// don't hold one).
|
||||
fn saved_model_cells(sheet: &Sheet, start: CellRef) -> Vec<CellRef> {
|
||||
let old = formulas_down(sheet, start);
|
||||
let rows: Vec<String> = old.iter().map(|(_, f)| f.clone()).collect();
|
||||
if rows.last().is_some_and(|f| is_options_row(f)) && parse_model_formulas(&rows).is_ok() {
|
||||
old.into_iter().map(|(c, _)| c).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// How many cells the options row of Save Model (`={…}`, one row) spans.
|
||||
fn options_width(formula: &str) -> u32 {
|
||||
u32::try_from(formula.split(',').count()).unwrap_or(u32::MAX)
|
||||
}
|
||||
|
||||
/// The options row is an array constant that spills `width - 1` cells to the right of `anchor`.
|
||||
/// Refused, before anything is written, when the spill would run off the sheet or be blocked
|
||||
/// (the anchor would show `#SPILL!`): a cell there holds content or another formula's spilled
|
||||
/// values, or a merged area or a table meets the row. `ours` are the cells Save Model
|
||||
/// writes or clears (a model saved at the same place before, whose options spill may be
|
||||
/// replaced); `used` are the model's own cells, which the spill must not cover.
|
||||
fn check_options_spill(sheet: &Sheet, anchor: CellRef, width: u32, ours: &[CellRef], used: &[CellRef]) -> Result<()> {
|
||||
if width <= 1 {
|
||||
return Ok(());
|
||||
}
|
||||
let end = anchor.col.checked_add(width - 1).filter(|c| *c < gridcraft_core::MAX_COLS).ok_or_else(|| {
|
||||
EngineError::Other(format!(
|
||||
"The saved model's options need {width} cells to the right from {}; choose another place to save it.",
|
||||
anchor.a1()
|
||||
))
|
||||
})?;
|
||||
let full = RangeRef::new(anchor, CellRef::new(anchor.row, end));
|
||||
let spill = RangeRef::new(CellRef::new(anchor.row, anchor.col.saturating_add(1)), CellRef::new(anchor.row, end));
|
||||
let blocked =
|
||||
|why: String| EngineError::Other(format!("The saved model's options fill {}, but {why}; choose another place to save it.", spill.a1()));
|
||||
if let Some(c) = used.iter().find(|c| spill.contains(**c)) {
|
||||
return Err(blocked(format!("{} is a cell of the model", c.a1())));
|
||||
}
|
||||
for c in spill.iter() {
|
||||
if sheet.cells.has(c) {
|
||||
return Err(blocked(format!("{} isn't empty", c.a1())));
|
||||
}
|
||||
if sheet.spill.contains_key(&c) && sheet.spill_anchor(c).is_none_or(|a| !ours.contains(&a)) {
|
||||
return Err(blocked(format!("{} holds the values of another formula", c.a1())));
|
||||
}
|
||||
}
|
||||
if sheet.merges.iter().any(|m| m.intersects(&full)) {
|
||||
return Err(blocked("merged cells are in the way".into()));
|
||||
}
|
||||
// A dynamic array can't spill into a table.
|
||||
if sheet.tables.iter().any(|t| t.range.intersects(&full)) {
|
||||
return Err(blocked("a table is in the way".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refused when a cell of the model's column holds another formula's spilled values: the
|
||||
/// formula written there would block that spill (the other formula would show `#SPILL!`).
|
||||
/// `ours` are as in [`check_options_spill`]: a model saved at the same place before may be
|
||||
/// replaced.
|
||||
fn check_column_spills(sheet: &Sheet, column: RangeRef, ours: &[CellRef]) -> Result<()> {
|
||||
for c in column.iter() {
|
||||
if sheet.spill.contains_key(&c) && sheet.spill_anchor(c).is_none_or(|a| !ours.contains(&a)) {
|
||||
return Err(EngineError::Other(format!(
|
||||
"The saved model would cover {}, which holds the values of another formula; choose another place to save it.",
|
||||
c.a1()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refused, unless `overwrite`, when a cell of the model's column holds a value, a formula or
|
||||
/// Python code (a model saved there before included): Save Model then asks before it replaces
|
||||
/// data, as Excel does. The message carries [`OVERWRITE_ERROR`], which the dialog turns into its
|
||||
/// question.
|
||||
fn check_column_empty(sheet: &Sheet, column: RangeRef, overwrite: bool) -> Result<()> {
|
||||
let taken = column.iter().find(|c| sheet.cell(*c).is_some_and(Cell::has_content));
|
||||
match taken {
|
||||
Some(c) if !overwrite => Err(EngineError::Other(format!(
|
||||
"The range {} {OVERWRITE_ERROR} ({} isn't empty); pass overwrite: true to replace it.",
|
||||
column.a1(),
|
||||
c.a1()
|
||||
))),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `data.solverSaveModel`: writes the model on cells of its sheet, in one step. Saving over a
|
||||
/// longer model saved at the same cell clears the rest of it, so Load Model reads the new one.
|
||||
pub(super) fn save_model_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let params = match p.get("model") {
|
||||
Some(m @ Json::Object(_)) => m.clone(),
|
||||
Some(Json::Null) | None => {
|
||||
saved_model(&d.wb, d.wb.active_sheet()).ok_or_else(|| EngineError::Other("This sheet has no saved Solver model.".into()))?
|
||||
}
|
||||
Some(_) => return Err(bad(SAVE_CMD, "`model` must be data.solver params")),
|
||||
};
|
||||
let model = parse_model(s, ¶ms)?;
|
||||
let to = cell_param_on(s, p, "to", model.sheet)?.ok_or_else(|| bad(SAVE_CMD, "missing `to` (the first cell of the saved model)"))?;
|
||||
let rows = model_formulas(&model)?;
|
||||
let count = u32::try_from(rows.len()).unwrap_or(u32::MAX);
|
||||
let last = to
|
||||
.row
|
||||
.checked_add(count.saturating_sub(1))
|
||||
.filter(|r| *r < gridcraft_core::MAX_ROWS)
|
||||
.ok_or_else(|| EngineError::Other("The saved model doesn't fit below that cell.".into()))?;
|
||||
let range = RangeRef::new(to, CellRef::new(last, to.col));
|
||||
// The model's own cells are never overwritten.
|
||||
let mut used = vec![model.objective];
|
||||
used.extend(&model.vars);
|
||||
for c in &model.cons {
|
||||
used.extend(&c.lhs);
|
||||
if let Rhs::Cells(r) = &c.rhs {
|
||||
used.extend(r);
|
||||
}
|
||||
}
|
||||
if used.iter().any(|c| range.contains(*c)) {
|
||||
return Err(EngineError::Other(format!("{} overlaps the model's cells; choose another place to save it.", range.a1())));
|
||||
}
|
||||
let sheet = model.sheet;
|
||||
let sh = s.doc()?.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
// What is left of a model saved here before; the new model's own cells stay.
|
||||
let stale: Vec<CellRef> = saved_model_cells(sh, to).into_iter().filter(|c| !range.contains(*c) && !used.contains(c)).collect();
|
||||
let ours: Vec<CellRef> = range.iter().chain(stale.iter().copied()).collect();
|
||||
let width = rows.last().map_or(1, |f| options_width(f));
|
||||
check_column_spills(sh, range, &ours)?;
|
||||
check_options_spill(sh, CellRef::new(last, to.col), width, &ours, &used)?;
|
||||
// Last, so the question is asked only when replacing the data would succeed.
|
||||
check_column_empty(sh, range, bool_param(p, "overwrite").unwrap_or(false))?;
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
let cells: Vec<CellRef> = range.iter().collect();
|
||||
for (c, f) in cells.iter().zip(&rows) {
|
||||
let mut cell = Cell::formula(gridcraft_model::Formula::new(f));
|
||||
cell.style = sh.style_id(*c);
|
||||
sh.set_cell(*c, cell);
|
||||
}
|
||||
for c in &stale {
|
||||
let style = sh.style_id(*c);
|
||||
sh.set_cell(*c, Cell { value: Value::Empty, formula: None, style, py: None });
|
||||
}
|
||||
for c in cells.into_iter().chain(stale) {
|
||||
cx.touch(sheet, c);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"range": range.a1()}))
|
||||
}
|
||||
|
||||
/// The formulas of a model range, in order. Every cell up to the last formula must hold one: a
|
||||
/// cell without a formula there (a constraint overwritten by a constant, say) would shift the
|
||||
/// rows below it and drop a constraint or the options. Empty cells after the last formula are
|
||||
/// allowed.
|
||||
fn cells_with_formulas(r: RangeRef, formula: impl Fn(CellRef) -> Option<String>) -> Result<Vec<String>> {
|
||||
let cells: Vec<(CellRef, Option<String>)> = r.iter().map(|c| (c, formula(c))).collect();
|
||||
let end = cells.iter().rposition(|(_, f)| f.is_some()).map_or(0, |i| i + 1);
|
||||
let mut rows = Vec::with_capacity(end);
|
||||
for (c, f) in cells.into_iter().take(end) {
|
||||
let f = f.ok_or_else(|| EngineError::Other(format!("{} holds no formula, so the range isn't a model saved with Save Model.", c.a1())))?;
|
||||
rows.push(f);
|
||||
}
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// A model read down from `start` must end with its options row, which Save Model always
|
||||
/// writes last: a cell without a formula before it (a constraint cleared, say) would otherwise
|
||||
/// end the read early and load the rows above it as the whole model, with default options.
|
||||
fn complete_from_cell(start: CellRef, down: &[(CellRef, String)]) -> Result<()> {
|
||||
match down.last() {
|
||||
Some((_, f)) if is_options_row(f) => Ok(()),
|
||||
_ if down.len() >= MAX_MODEL_ROWS => Err(EngineError::Other(format!(
|
||||
"No options row ends the model within {MAX_MODEL_ROWS} cells of {}, so it isn't a model saved with Save Model.",
|
||||
start.a1()
|
||||
))),
|
||||
last => {
|
||||
let gap = last.map_or(Some(start), |(c, _)| c.row.checked_add(1).map(|row| CellRef::new(row, c.col)));
|
||||
Err(EngineError::Other(match gap {
|
||||
Some(gap) => format!(
|
||||
"{} holds no formula before the model's options row, so {} doesn't start a complete model saved with Save Model.",
|
||||
gap.a1(),
|
||||
start.a1()
|
||||
),
|
||||
None => format!("{} doesn't start a complete model saved with Save Model.", start.a1()),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `data.solverLoadModel`: makes a model written by Save Model the active sheet's model.
|
||||
pub(super) fn load_model_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let active = d.wb.active_sheet();
|
||||
let text = str_param(p, "from").ok_or_else(|| bad(LOAD_CMD, "missing `from` (the saved model's range)"))?;
|
||||
let (sh, body) = split_sheet(text);
|
||||
if sh.is_some_and(|n| d.wb.sheet_index(&n) != Some(active)) {
|
||||
return Err(EngineError::Other("Load Model reads a model saved on the active sheet.".into()));
|
||||
}
|
||||
let r = RangeRef::parse(body.trim()).ok_or_else(|| bad(LOAD_CMD, format!("not a range: `{text}`")))?;
|
||||
let cap = MAX_MODEL_ROWS;
|
||||
let sheet = d.wb.sheet(active).ok_or(EngineError::NoDocument)?;
|
||||
let formula = |c: CellRef| sheet.cell(c).and_then(|x| x.formula.as_ref()).map(|f| f.text.clone());
|
||||
let rows: Vec<String> = if r.is_single() {
|
||||
let down = formulas_down(sheet, r.start);
|
||||
complete_from_cell(r.start, &down)?;
|
||||
down.into_iter().map(|(_, f)| f).collect()
|
||||
} else {
|
||||
if r.count() > cap as u64 {
|
||||
return Err(EngineError::Other(format!("The range is too large for a saved model (at most {cap} cells).")));
|
||||
}
|
||||
let rows = cells_with_formulas(r, formula)?;
|
||||
if !rows.last().is_some_and(|f| is_options_row(f)) {
|
||||
return Err(EngineError::Other(format!(
|
||||
"{} doesn't end with the model's options row, so it isn't a complete model saved with Save Model.",
|
||||
r.a1()
|
||||
)));
|
||||
}
|
||||
rows
|
||||
};
|
||||
let params = parse_model_formulas(&rows)?;
|
||||
let model = parse_model(s, ¶ms)?;
|
||||
if model.sheet != active {
|
||||
return Err(EngineError::Other("Load Model reads a model of the active sheet.".into()));
|
||||
}
|
||||
edit(s, |cx| {
|
||||
save_model(&mut cx.wb, &model);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"model": saved_model(&s.doc()?.wb, active)}))
|
||||
}
|
||||
|
||||
/// Whether a defined name belongs to the saved model of `sheet`.
|
||||
fn is_sheet_model_name(n: &DefinedName, sheet: usize) -> bool {
|
||||
n.scope == Some(sheet) && is_model_name(&n.name)
|
||||
}
|
||||
|
||||
/// `data.solverReset`: Reset All removes the active sheet's model. A model the sheet reads from
|
||||
/// workbook-level names (which other sheets may read too) is left in place and masked by an
|
||||
/// empty sheet-level `solver_opt`, so no other sheet's model changes.
|
||||
pub(super) fn reset_model(s: &mut Session, _p: &Json) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let active = d.wb.active_sheet();
|
||||
let removed = d.wb.names.iter().filter(|n| is_sheet_model_name(n, active)).count();
|
||||
let mut after = d.wb.names.clone();
|
||||
after.retain(|n| !is_sheet_model_name(n, active));
|
||||
let masked = {
|
||||
let mut probe = (*d.wb).clone();
|
||||
probe.names = after;
|
||||
saved_model(&probe, active).is_some()
|
||||
};
|
||||
if removed > 0 || masked {
|
||||
edit(s, |cx| {
|
||||
cx.wb.names.retain(|n| !is_sheet_model_name(n, active));
|
||||
if masked {
|
||||
cx.wb.names.push(DefinedName {
|
||||
name: "solver_opt".into(),
|
||||
scope: Some(active),
|
||||
formula: "\"\"".into(),
|
||||
comment: String::new(),
|
||||
hidden: true,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({"removed": removed, "masked": masked}))
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
use super::lp::Outcome;
|
||||
use super::model::{Constraint, Goal, Method, Model, Rel, Rhs, parse_model, saved_model};
|
||||
use super::problem::{Evaluator, Problem, as_num, build_problem};
|
||||
use super::sensitivity::{sensitivity_refusal, sensitivity_report};
|
||||
use super::{
|
||||
Cell, CellRef, Ctx, DEFAULT_EVALS, DocState, EngineError, Json, MAX_EVALS, PRECISION, REPORT_CMD, Result, Session, Sheet, Trial, Value, bad,
|
||||
edit, json, optimise, str_param,
|
||||
Cell, CellRef, Ctx, DocState, EngineError, Json, PRECISION, REPORT_CMD, Result, Session, Sheet, Trial, Value, bad, bool_param, edit, json,
|
||||
optimise, str_param,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- reports
|
||||
@@ -11,6 +12,7 @@ use super::{
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) enum ReportKind {
|
||||
Answer,
|
||||
Sensitivity,
|
||||
Limits,
|
||||
}
|
||||
|
||||
@@ -18,6 +20,7 @@ impl ReportKind {
|
||||
fn title(self) -> &'static str {
|
||||
match self {
|
||||
ReportKind::Answer => "Answer",
|
||||
ReportKind::Sensitivity => "Sensitivity",
|
||||
ReportKind::Limits => "Limits",
|
||||
}
|
||||
}
|
||||
@@ -26,13 +29,14 @@ impl ReportKind {
|
||||
/// The `reports` list (`None` when absent).
|
||||
pub(super) fn report_kinds(cmd: &'static str, p: &Json) -> Result<Option<Vec<ReportKind>>> {
|
||||
let Some(list) = p.get("reports") else { return Ok(None) };
|
||||
let list = list.as_array().ok_or_else(|| bad(cmd, "`reports` must be an array of answer and limits"))?;
|
||||
let list = list.as_array().ok_or_else(|| bad(cmd, "`reports` must be an array of answer, sensitivity and limits"))?;
|
||||
let mut kinds = Vec::new();
|
||||
for k in list {
|
||||
let kind = match k.as_str().map(|t| t.trim().to_ascii_lowercase()).as_deref() {
|
||||
Some("answer") => ReportKind::Answer,
|
||||
Some("sensitivity") => ReportKind::Sensitivity,
|
||||
Some("limits") => ReportKind::Limits,
|
||||
_ => return Err(bad(cmd, format!("unknown report {k} (answer or limits)"))),
|
||||
_ => return Err(bad(cmd, format!("unknown report {k} (answer, sensitivity or limits)"))),
|
||||
};
|
||||
if !kinds.contains(&kind) {
|
||||
kinds.push(kind);
|
||||
@@ -42,14 +46,14 @@ pub(super) fn report_kinds(cmd: &'static str, p: &Json) -> Result<Option<Vec<Rep
|
||||
}
|
||||
|
||||
/// A report's cells: (row, column, value, bold).
|
||||
type Lines = Vec<(u32, u32, Value, bool)>;
|
||||
pub(super) type Lines = Vec<(u32, u32, Value, bool)>;
|
||||
|
||||
fn abs_ref(c: CellRef) -> String {
|
||||
pub(super) fn abs_ref(c: CellRef) -> String {
|
||||
format!("${}${}", gridcraft_core::col_to_letters(c.col), u64::from(c.row) + 1)
|
||||
}
|
||||
|
||||
/// A cell's name in the reports: the nearest text to its left and the nearest text above it.
|
||||
fn cell_name(sh: &Sheet, c: CellRef) -> String {
|
||||
pub(super) fn cell_name(sh: &Sheet, c: CellRef) -> String {
|
||||
let text = |c: CellRef| match sh.value(c) {
|
||||
Value::Text(t) if !t.trim().is_empty() => Some(t.to_string()),
|
||||
_ => None,
|
||||
@@ -68,7 +72,7 @@ fn method_name(m: Method) -> &'static str {
|
||||
}
|
||||
|
||||
/// Header labels in bold from column 1, then the rows below them; returns the next free row.
|
||||
fn table(out: &mut Lines, row: u32, headers: &[&str], rows: Vec<Vec<Value>>) -> u32 {
|
||||
pub(super) fn table(out: &mut Lines, row: u32, headers: &[&str], rows: Vec<Vec<Value>>) -> u32 {
|
||||
for (k, h) in headers.iter().enumerate() {
|
||||
out.push((row, 1 + k as u32, Value::text(*h), true));
|
||||
}
|
||||
@@ -92,7 +96,7 @@ pub(super) struct ReportInput<'a> {
|
||||
pub(super) budget: u64,
|
||||
}
|
||||
|
||||
fn title(out: &mut Lines, kind: ReportKind, sheet: &str) -> u32 {
|
||||
pub(super) fn title(out: &mut Lines, kind: ReportKind, sheet: &str) -> u32 {
|
||||
out.push((0, 0, Value::text(format!("GridCraft {} Report", kind.title())), true));
|
||||
out.push((1, 0, Value::text(format!("Worksheet: {sheet}")), false));
|
||||
3
|
||||
@@ -255,18 +259,67 @@ fn limits_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Result<Lines> {
|
||||
}
|
||||
|
||||
/// Builds every report before the solution or saved model can change.
|
||||
pub(super) fn prepare_reports(d: &DocState, kinds: &[ReportKind], inp: &ReportInput) -> Result<Vec<(String, Lines)>> {
|
||||
/// What happens to a Sensitivity report the model allows but the solution doesn't: `Refuse`
|
||||
/// fails the call before anything changes; `Skip` leaves the report out, with its reason, and
|
||||
/// writes the others. `optimum` is false when the solve stopped before an optimum, so the
|
||||
/// report isn't attempted.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) enum NotOptimal {
|
||||
Refuse,
|
||||
Skip { optimum: bool },
|
||||
}
|
||||
|
||||
/// The reports left out under [`NotOptimal::Skip`], as `{report, reason}`.
|
||||
pub(super) type Skipped = Vec<Json>;
|
||||
|
||||
pub(super) fn prepare_reports(
|
||||
d: &DocState,
|
||||
kinds: &[ReportKind],
|
||||
inp: &ReportInput,
|
||||
not_optimal: NotOptimal,
|
||||
) -> Result<(Vec<(String, Lines)>, Skipped)> {
|
||||
if kinds.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
if d.wb.protected_structure {
|
||||
return Err(EngineError::Other("The workbook is protected and cannot be changed.".into()));
|
||||
// A request that must write a sheet is refused before any report is built (a Limits report
|
||||
// runs two optimisations per variable); only a Sensitivity report that may be skipped waits
|
||||
// for the check after the loop.
|
||||
let must_write = kinds.iter().any(|k| *k != ReportKind::Sensitivity || not_optimal == NotOptimal::Refuse);
|
||||
if must_write && d.wb.protected_structure {
|
||||
return Err(protected());
|
||||
}
|
||||
// A model that has no Sensitivity report (integer, Evolutionary, Value Of) is refused in
|
||||
// both modes before any report is built: a Limits report listed first would otherwise run
|
||||
// its two optimisations per variable for an error. Only the solution's own refusal (not
|
||||
// optimal) waits for the loop, where `Skip` may leave the report out.
|
||||
if kinds.contains(&ReportKind::Sensitivity)
|
||||
&& let Some(why) = sensitivity_refusal(inp.model, inp.prob)
|
||||
{
|
||||
return Err(EngineError::Other(why.into()));
|
||||
}
|
||||
let sh = d.wb.sheet(inp.model.sheet).ok_or(EngineError::NoDocument)?;
|
||||
let mut built = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
for k in kinds {
|
||||
let lines = match k {
|
||||
ReportKind::Answer => answer_report(d, sh, inp),
|
||||
ReportKind::Sensitivity => match not_optimal {
|
||||
NotOptimal::Skip { optimum } => {
|
||||
let got = if optimum {
|
||||
sensitivity_report(d, sh, inp).map_err(|e| e.to_string())
|
||||
} else {
|
||||
Err("Solver did not reach an optimal solution, so the Sensitivity report was not written.".to_string())
|
||||
};
|
||||
match got {
|
||||
Ok(lines) => lines,
|
||||
Err(reason) => {
|
||||
skipped.push(json!({"report": "sensitivity", "reason": reason}));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
NotOptimal::Refuse => sensitivity_report(d, sh, inp)?,
|
||||
},
|
||||
ReportKind::Limits => limits_report(d, sh, inp)?,
|
||||
};
|
||||
let name = (1..=10_000u32)
|
||||
@@ -275,7 +328,16 @@ pub(super) fn prepare_reports(d: &DocState, kinds: &[ReportKind], inp: &ReportIn
|
||||
.ok_or_else(|| EngineError::Other("Too many report sheets.".into()))?;
|
||||
built.push((name, lines));
|
||||
}
|
||||
Ok(built)
|
||||
// Only a report sheet changes the workbook's structure, so a request whose reports were all
|
||||
// skipped still succeeds in a protected workbook.
|
||||
if !built.is_empty() && d.wb.protected_structure {
|
||||
return Err(protected());
|
||||
}
|
||||
Ok((built, skipped))
|
||||
}
|
||||
|
||||
fn protected() -> EngineError {
|
||||
EngineError::Other("The workbook is protected and cannot be changed.".into())
|
||||
}
|
||||
|
||||
/// Inserts prepared reports before the model's sheet, which stays active.
|
||||
@@ -290,9 +352,9 @@ pub(super) fn write_reports(cx: &mut Ctx, at: usize, built: Vec<(String, Lines)>
|
||||
let mut sheet = Sheet::new(name);
|
||||
for (r, c, v, b) in lines {
|
||||
let style = if b { bold } else { gridcraft_model::StyleId::DEFAULT };
|
||||
sheet.set_cell(CellRef::new(r, c), Cell { value: v, formula: None, style });
|
||||
sheet.set_cell(CellRef::new(r, c), Cell { value: v, formula: None, style, py: None });
|
||||
}
|
||||
for (c, w) in [(0u32, 14.0f32), (1, 110.0), (2, 140.0), (3, 120.0), (4, 120.0), (5, 120.0), (6, 120.0), (7, 120.0)] {
|
||||
for (c, w) in [(0u32, 14.0f32), (1, 110.0), (2, 140.0), (3, 120.0), (4, 120.0), (5, 155.0), (6, 140.0), (7, 140.0)] {
|
||||
sheet.cols.insert(c, gridcraft_model::LineInfo { size: Some(w), ..Default::default() });
|
||||
}
|
||||
cx.wb.sheets.insert(at + i, std::sync::Arc::new(sheet));
|
||||
@@ -321,9 +383,9 @@ pub(super) fn insert_report_selections(s: &mut Session, at: usize, count: usize)
|
||||
|
||||
/// `data.solverReport`: reports for a solution of the sheet's saved model.
|
||||
pub(super) fn report(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let kinds = report_kinds(REPORT_CMD, p)?.ok_or_else(|| bad(REPORT_CMD, "missing `reports` (answer, limits)"))?;
|
||||
let kinds = report_kinds(REPORT_CMD, p)?.ok_or_else(|| bad(REPORT_CMD, "missing `reports` (answer, sensitivity, limits)"))?;
|
||||
if kinds.is_empty() {
|
||||
return Err(bad(REPORT_CMD, "`reports` is empty (answer, limits)"));
|
||||
return Err(bad(REPORT_CMD, "`reports` is empty (answer, sensitivity, limits)"));
|
||||
}
|
||||
let d = s.doc()?;
|
||||
// The given model as is, else the saved model with any params given here over it.
|
||||
@@ -362,15 +424,17 @@ pub(super) fn report(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
};
|
||||
let solution = point("values")?;
|
||||
let original = if p.get("original").is_some() { point("original")? } else { solution.clone() };
|
||||
let budget = params
|
||||
.get("maxEvaluations")
|
||||
.and_then(Json::as_f64)
|
||||
.filter(|v| v.is_finite() && *v >= 1.0)
|
||||
.map_or(DEFAULT_EVALS, |v| (v as u64).min(MAX_EVALS));
|
||||
let budget = model.max_evals;
|
||||
let message = str_param(p, "message").unwrap_or("").chars().take(500).collect();
|
||||
let inp = ReportInput { model: &model, prob: &prob, original, solution, message, budget };
|
||||
let built = prepare_reports(s.doc()?, &kinds, &inp)?;
|
||||
let sheets = edit(s, |cx| Ok(write_reports(cx, model.sheet, built)))?;
|
||||
let not_optimal = if bool_param(p, "skipNotOptimal").unwrap_or(false) { NotOptimal::Skip { optimum: true } } else { NotOptimal::Refuse };
|
||||
let (built, skipped) = prepare_reports(s.doc()?, &kinds, &inp, not_optimal)?;
|
||||
// Nothing left to write (every report skipped): no edit, so no Undo step.
|
||||
let sheets = if built.is_empty() { Vec::new() } else { edit(s, |cx| Ok(write_reports(cx, model.sheet, built)))? };
|
||||
insert_report_selections(s, model.sheet, sheets.len());
|
||||
Ok(json!({"sheets": sheets}))
|
||||
let mut out = json!({"sheets": sheets});
|
||||
if !skipped.is_empty() {
|
||||
out["skippedReports"] = json!(skipped);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
//! The Sensitivity report. Simplex LP: each variable's reduced cost and the range of its
|
||||
//! objective coefficient over which the basis stays optimal, and each constraint's shadow price
|
||||
//! and the range of its right side over which the shadow price holds, from the final tableau of
|
||||
//! the LP linearised at the solution. GRG Nonlinear: the reduced gradients and the Lagrange
|
||||
//! multipliers of the binding constraints at the solution, from difference gradients.
|
||||
|
||||
use super::lp::{FinalTableau, LpRow, Outcome, Row, Sense, SimplexProgram, simplex_program, solve_lp_tableau};
|
||||
use super::model::{Goal, Method, Model};
|
||||
use super::problem::{Evaluator, Point, Problem, as_num};
|
||||
use super::reports::{Lines, ReportInput, ReportKind, abs_ref, cell_name, table, title};
|
||||
use super::{CellRef, DocState, EPS, EngineError, Result, Sheet, Trial, Value};
|
||||
|
||||
/// Excel prints an unlimited allowable change as 1E+30.
|
||||
const NO_LIMIT: f64 = 1e30;
|
||||
|
||||
/// Most arithmetic the Lagrange multipliers of a nonlinear model may take (about a second): a
|
||||
/// pass over k binding rows and f free variables costs about 3·k²·f (the normal equations and
|
||||
/// each multiplier's noise), and each pass drops at most one row, so a model at the constraint
|
||||
/// limit could otherwise take minutes.
|
||||
#[cfg(not(test))]
|
||||
const MULTIPLIER_WORK: u64 = 1_000_000_000;
|
||||
/// A low limit in tests, so the refusal is reachable with a small model.
|
||||
#[cfg(test)]
|
||||
const MULTIPLIER_WORK: u64 = 20_000;
|
||||
|
||||
/// Why a model has no Sensitivity report (`None` when it has one).
|
||||
pub(super) fn sensitivity_refusal(m: &Model, prob: &Problem) -> Option<&'static str> {
|
||||
if prob.integer.iter().any(|b| *b) {
|
||||
Some("The Sensitivity report doesn't apply to models with integer or binary constraints.")
|
||||
} else if m.method == Method::Evolutionary {
|
||||
Some("The Sensitivity report is available for Simplex LP and GRG Nonlinear.")
|
||||
} else if matches!(m.goal, Goal::Value(_)) {
|
||||
Some("The Sensitivity report needs an objective to maximise or minimise.")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A sensitivity figure: values within `noise` of zero (rounding) print as 0, unlimited ones
|
||||
/// as 1E+30. The noise level is local to the figure: it comes from the terms the figure is a
|
||||
/// sum of (the rounding error of a sum scales with the sum of its terms' sizes), never from
|
||||
/// the model's largest coefficient or gradient component, so a small price or gradient keeps
|
||||
/// its value beside a large unrelated one, and never from the objective's value, so adding a
|
||||
/// constant to the objective changes nothing the report shows.
|
||||
fn sens_num(v: f64, noise: f64) -> Value {
|
||||
if !v.is_finite() || v >= NO_LIMIT {
|
||||
Value::number(NO_LIMIT)
|
||||
} else if v.abs() <= noise {
|
||||
Value::number(0.0)
|
||||
} else {
|
||||
Value::number(v)
|
||||
}
|
||||
}
|
||||
|
||||
fn num(v: f64) -> Value {
|
||||
if v.is_finite() { Value::number(v) } else { Value::Error(gridcraft_core::CellError::Value) }
|
||||
}
|
||||
|
||||
/// The smallest ratio `num / den` over the `den` above zero (the step to the first bound).
|
||||
fn min_ratio(pairs: impl Iterator<Item = (f64, f64)>) -> f64 {
|
||||
pairs.filter(|(_, den)| *den > EPS).map(|(num, den)| num.max(0.0) / den).fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
/// Solves the square system `a·x = b` by Gaussian elimination with partial pivoting; unknowns
|
||||
/// of a singular system that have no pivot are 0.
|
||||
fn solve_dense(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Vec<f64> {
|
||||
let n = b.len();
|
||||
let scale = a.iter().flatten().fold(0.0f64, |m, v| m.max(v.abs())).max(f64::MIN_POSITIVE);
|
||||
let mut pivots: Vec<Option<usize>> = vec![None; n];
|
||||
let mut row = 0;
|
||||
for col in 0..n {
|
||||
let Some(p) = (row..n).max_by(|i, k| {
|
||||
let v = |r: &usize| a.get(*r).and_then(|x| x.get(col)).map_or(0.0, |v| v.abs());
|
||||
v(i).total_cmp(&v(k))
|
||||
}) else {
|
||||
break;
|
||||
};
|
||||
if a.get(p).and_then(|x| x.get(col)).is_none_or(|v| v.abs() <= 1e-12 * scale) {
|
||||
continue;
|
||||
}
|
||||
a.swap(row, p);
|
||||
b.swap(row, p);
|
||||
let (Some(prow), Some(pb)) = (a.get(row).cloned(), b.get(row).copied()) else { break };
|
||||
let pv = prow.get(col).copied().unwrap_or(1.0);
|
||||
for (i, (r, bi)) in a.iter_mut().zip(b.iter_mut()).enumerate() {
|
||||
if i == row {
|
||||
continue;
|
||||
}
|
||||
let f = r.get(col).copied().unwrap_or(0.0) / pv;
|
||||
if f != 0.0 {
|
||||
for (v, p) in r.iter_mut().zip(&prow) {
|
||||
*v -= f * p;
|
||||
}
|
||||
*bi -= f * pb;
|
||||
}
|
||||
}
|
||||
if let Some(e) = pivots.get_mut(col) {
|
||||
*e = Some(row);
|
||||
}
|
||||
row += 1;
|
||||
}
|
||||
pivots
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(col, p)| match p {
|
||||
Some(r) => b.get(*r).copied().unwrap_or(0.0) / a.get(*r).and_then(|x| x.get(col)).copied().unwrap_or(1.0),
|
||||
None => 0.0,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn out_of_evaluations() -> EngineError {
|
||||
EngineError::Other("Solver ran out of evaluations.".into())
|
||||
}
|
||||
|
||||
fn not_optimal() -> EngineError {
|
||||
EngineError::Other("The Sensitivity report needs an optimal solution: these values are not optimal for the model. Solve it first.".into())
|
||||
}
|
||||
|
||||
/// What both report bodies share: the model, the solution, its objective value and the rows'
|
||||
/// final values and right sides.
|
||||
struct At<'a> {
|
||||
inp: &'a ReportInput<'a>,
|
||||
sh: &'a Sheet,
|
||||
point: Point,
|
||||
finals: Vec<(f64, f64)>,
|
||||
}
|
||||
|
||||
impl At<'_> {
|
||||
fn var_head(&self, j: usize, c: CellRef) -> Vec<Value> {
|
||||
vec![Value::text(abs_ref(c)), Value::text(cell_name(self.sh, c)), num(self.inp.solution.get(j).copied().unwrap_or(f64::NAN))]
|
||||
}
|
||||
fn row_head(&self, i: usize, r: &Row) -> Vec<Value> {
|
||||
let fin = self.finals.get(i).map_or(f64::NAN, |f| f.0);
|
||||
vec![Value::text(abs_ref(r.lhs)), Value::text(cell_name(self.sh, r.lhs)), num(fin)]
|
||||
}
|
||||
}
|
||||
|
||||
/// The variable rows, the constraint rows and their headers.
|
||||
type Body = (Vec<Vec<Value>>, Vec<Vec<Value>>, Vec<&'static str>, Vec<&'static str>);
|
||||
|
||||
pub(super) fn sensitivity_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Result<Lines> {
|
||||
let m = inp.model;
|
||||
if let Some(why) = sensitivity_refusal(m, inp.prob) {
|
||||
return Err(EngineError::Other(why.into()));
|
||||
}
|
||||
let n = m.vars.len();
|
||||
let x = &inp.solution;
|
||||
let mut ev = Evaluator {
|
||||
trial: Trial::new(d),
|
||||
sheet: m.sheet,
|
||||
vars: m.vars.clone(),
|
||||
objective: m.objective,
|
||||
rows: inp.prob.rows.clone(),
|
||||
evals: 0,
|
||||
// Room for the gradients (GRG) or two linearisations (Simplex LP) at the least.
|
||||
budget: inp.budget.max(3 * n as u64 + 4),
|
||||
target: None,
|
||||
};
|
||||
let point = ev.eval(x).ok_or_else(out_of_evaluations)?;
|
||||
// The rows' final values and right sides at the solution.
|
||||
let mut cells = Vec::new();
|
||||
for r in &inp.prob.rows {
|
||||
cells.push(r.lhs);
|
||||
cells.extend(r.rhs);
|
||||
}
|
||||
let mut trial = Trial::new(d);
|
||||
let sets: Vec<(usize, CellRef, Value)> = m.vars.iter().zip(x).map(|(c, v)| (m.sheet, *c, Value::number(*v))).collect();
|
||||
let reads: Vec<(usize, CellRef)> = cells.iter().map(|c| (m.sheet, *c)).collect();
|
||||
let mut vals = trial.run(&sets, &reads).into_iter().map(|v| as_num(&v));
|
||||
let finals: Vec<(f64, f64)> = inp
|
||||
.prob
|
||||
.rows
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let l = vals.next().unwrap_or(f64::NAN);
|
||||
(l, if r.rhs.is_some() { vals.next().unwrap_or(f64::NAN) } else { r.rhs_const })
|
||||
})
|
||||
.collect();
|
||||
let at = At { inp, sh, point, finals };
|
||||
let (var_rows, con_rows, var_headers, con_headers) =
|
||||
if m.method == Method::Simplex { simplex_body(&mut ev, &at)? } else { nonlinear_body(&mut ev, &at)? };
|
||||
let mut out = Lines::new();
|
||||
let r = title(&mut out, ReportKind::Sensitivity, &sh.name);
|
||||
out.push((r, 0, Value::text("Variable Cells"), true));
|
||||
let r = table(&mut out, r + 1, &var_headers, var_rows);
|
||||
out.push((r, 0, Value::text("Constraints"), true));
|
||||
table(&mut out, r + 1, &con_headers, con_rows);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Simplex LP: reduced costs, shadow prices and their ranges from the final tableau.
|
||||
fn simplex_body(ev: &mut Evaluator, at: &At) -> Result<Body> {
|
||||
let (inp, m) = (at.inp, at.inp.model);
|
||||
// The program the solve built, from the same start values (data.solver's x0, which Solver
|
||||
// Results passes back as `original`), so the solve below repeats its pivots. Start values
|
||||
// the model can't be evaluated at (given only to the report) fall back to the solution.
|
||||
let start: Vec<f64> = inp.original.iter().map(|v| if v.is_finite() { *v } else { 0.0 }).collect();
|
||||
let built = match simplex_program(ev, m.goal, &start) {
|
||||
Err("nonlinear" | "error") if start != inp.solution => simplex_program(ev, m.goal, &inp.solution),
|
||||
other => other,
|
||||
};
|
||||
let SimplexProgram { cf, c, rows, mut work } = built.map_err(|e| {
|
||||
if e == "budget" {
|
||||
out_of_evaluations()
|
||||
} else {
|
||||
EngineError::Other("The model isn't linear in the variable cells, so Simplex LP can't report its sensitivity.".into())
|
||||
}
|
||||
})?;
|
||||
let (outcome, tab) = solve_lp_tableau(&c, &rows, &inp.prob.lo, &inp.prob.hi, &mut work);
|
||||
let (Outcome::Solved(vertex, best, _), Some(tab)) = (outcome, tab) else {
|
||||
return Err(EngineError::Other("Solver could not find an optimal basis for the Sensitivity report.".into()));
|
||||
};
|
||||
// The report describes the optimum, so the values it shows must be one.
|
||||
if !optimal_point(&c, &rows, (&inp.prob.lo, &inp.prob.hi), &inp.solution, best) {
|
||||
return Err(not_optimal());
|
||||
}
|
||||
// The prices and ranges come from the tableau of `vertex`, so the values shown must be that
|
||||
// vertex. Another optimum with the same objective (when the optimum is not unique) has other
|
||||
// ranges, so it is refused too, although it is optimal.
|
||||
let near = |a: f64, b: f64| (a - b).abs() <= super::PRECISION * (1.0 + a.abs().max(b.abs()));
|
||||
if vertex.len() != inp.solution.len() || !vertex.iter().zip(&inp.solution).all(|(a, b)| near(*a, *b)) {
|
||||
return Err(EngineError::Other(
|
||||
"The Sensitivity report needs the values at the Simplex LP solution: these values are another optimal solution, whose ranges differ. Solve the model with Simplex LP first."
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
// Minimising `sign · objective`.
|
||||
let sign = if m.goal == Goal::Max { -1.0 } else { 1.0 };
|
||||
// A shadow price is the dual yᵢ = Σᵣ c_B(r)·B⁻¹(r, i): the row's unit column of the final
|
||||
// tableau holds B⁻¹'s column i, and c_B(r) is the cost of the column basic in row r (a
|
||||
// slack or artificial costs nothing). The rounding error of a sum scales with the sum of
|
||||
// its terms' sizes, so a price below 1E-9 of Σᵣ |c_B(r)·B⁻¹(r, i)| is noise. The bound is
|
||||
// the row's own: a small price keeps its value beside a large coefficient that plays no
|
||||
// part in it, and a constant in the objective (no coefficient) changes no bound. Allowable
|
||||
// changes to a coefficient or a right side are ratios the tableau's own pivot tolerance
|
||||
// (`EPS`) already bounds.
|
||||
let cost_size = |b: usize| tab.cols.get(b).map_or(0.0, |mp| cf.get(mp.var).copied().unwrap_or(0.0).abs());
|
||||
let price_terms = |col: usize| (0..tab.t.len()).map(|r| tab.basis.get(r).map_or(0.0, |b| cost_size(*b)) * entry(&tab, r, col).abs()).sum::<f64>();
|
||||
let (shadow, terms): (Vec<f64>, Vec<f64>) = (0..rows.len())
|
||||
.map(|i| {
|
||||
let col = tab.ident.get(i).copied().unwrap_or(usize::MAX);
|
||||
(sign * flip(&tab, i) * -tab.obj.get(col).copied().unwrap_or(0.0), price_terms(col))
|
||||
})
|
||||
.unzip();
|
||||
let noise = |terms: f64| 1e-9 * terms.max(f64::MIN_POSITIVE);
|
||||
let con_rows = ev
|
||||
.rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, r)| {
|
||||
// The basic values move along the row's unit column as its right side changes.
|
||||
let col = tab.ident.get(i).copied().unwrap_or(usize::MAX);
|
||||
let dir: Vec<(f64, f64)> =
|
||||
(0..tab.t.len()).filter(|r| real_basic(&tab, *r)).map(|r| (entry(&tab, r, tab.width), flip(&tab, i) * entry(&tab, r, col))).collect();
|
||||
// An artificial still basic after phase one stands for a row the others make
|
||||
// redundant, and it is zero there. When this right side moves it, any change alone
|
||||
// leaves no solution, so both ranges are 0.
|
||||
let pinned = (0..tab.t.len()).any(|r| !real_basic(&tab, r) && entry(&tab, r, col).abs() > EPS);
|
||||
let (increase, decrease) =
|
||||
if pinned { (0.0, 0.0) } else { (min_ratio(dir.iter().map(|(v, d)| (*v, -d))), min_ratio(dir.iter().map(|(v, d)| (*v, *d)))) };
|
||||
let b = at.finals.get(i).map_or(f64::NAN, |f| f.1);
|
||||
let mut row = at.row_head(i, r);
|
||||
let price = sens_num(shadow.get(i).copied().unwrap_or(0.0), noise(terms.get(i).copied().unwrap_or(0.0)));
|
||||
row.extend([price, num(b), sens_num(increase, EPS), sens_num(decrease, EPS)]);
|
||||
row
|
||||
})
|
||||
.collect();
|
||||
let nonbasic: Vec<usize> = (0..tab.first_art).filter(|l| !tab.basis.contains(l)).collect();
|
||||
let var_rows = m
|
||||
.vars
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(j, c)| {
|
||||
let coef = cf.get(j).copied().unwrap_or(0.0);
|
||||
let used: f64 = rows.iter().zip(&shadow).map(|((a, _, _), p)| a.get(j).copied().unwrap_or(0.0) * p).sum();
|
||||
// The reduced cost cf_j − Σᵢ a_ij·yᵢ is a sum of cf_j and the prices' terms times
|
||||
// a_ij: the prices' terms rather than the prices, since a price that is a small
|
||||
// difference of large terms carries their rounding error into every reduced cost
|
||||
// it is used in.
|
||||
let used_terms: f64 = rows.iter().zip(&terms).map(|((a, _, _), t)| a.get(j).copied().unwrap_or(0.0).abs() * t).sum();
|
||||
// A change Δ in the coefficient changes each nonbasic reduced cost by Δ·e.
|
||||
let own = |col: usize| tab.cols.get(col).filter(|mp| mp.var == j).map_or(0.0, |mp| mp.sign);
|
||||
let moves: Vec<(f64, f64)> = nonbasic
|
||||
.iter()
|
||||
.map(|&l| {
|
||||
let through_basis: f64 = (0..tab.t.len()).map(|r| tab.basis.get(r).map_or(0.0, |b| own(*b)) * entry(&tab, r, l)).sum();
|
||||
(tab.obj.get(l).copied().unwrap_or(0.0), sign * (own(l) - through_basis))
|
||||
})
|
||||
.collect();
|
||||
let increase = min_ratio(moves.iter().map(|(d, e)| (*d, -e)));
|
||||
let decrease = min_ratio(moves.iter().map(|(d, e)| (*d, *e)));
|
||||
let mut row = at.var_head(j, *c);
|
||||
row.extend([sens_num(coef - used, noise(coef.abs() + used_terms)), num(coef), sens_num(increase, EPS), sens_num(decrease, EPS)]);
|
||||
row
|
||||
})
|
||||
.collect();
|
||||
Ok((
|
||||
var_rows,
|
||||
con_rows,
|
||||
vec!["Cell", "Name", "Final Value", "Reduced Cost", "Objective Coefficient", "Allowable Increase", "Allowable Decrease"],
|
||||
vec!["Cell", "Name", "Final Value", "Shadow Price", "Constraint R.H. Side", "Allowable Increase", "Allowable Decrease"],
|
||||
))
|
||||
}
|
||||
|
||||
/// Whether `x` satisfies the LP (rows and bounds) and reaches its optimum `best` (minimising
|
||||
/// `c·x`), within the constraint precision.
|
||||
fn optimal_point(c: &[f64], rows: &[LpRow], (lo, hi): (&[f64], &[f64]), x: &[f64], best: f64) -> bool {
|
||||
let near = |v: f64, size: f64| v <= super::PRECISION * (1.0 + size);
|
||||
let dot = |a: &[f64]| a.iter().zip(x).fold((0.0, 0.0), |(s, m), (p, q)| (s + p * q, m + (p * q).abs()));
|
||||
let rows_hold = rows.iter().all(|(a, sense, b)| {
|
||||
let (lhs, size) = dot(a);
|
||||
let size = size + b.abs();
|
||||
match sense {
|
||||
Sense::Le => near(lhs - b, size),
|
||||
Sense::Ge => near(b - lhs, size),
|
||||
Sense::Eq => near((lhs - b).abs(), size),
|
||||
}
|
||||
});
|
||||
let bounds_hold = x.iter().enumerate().all(|(j, v)| {
|
||||
let (l, h) = (lo.get(j).copied().unwrap_or(f64::NEG_INFINITY), hi.get(j).copied().unwrap_or(f64::INFINITY));
|
||||
v.is_finite() && near(l - v, v.abs()) && near(v - h, v.abs())
|
||||
});
|
||||
let (value, size) = dot(c);
|
||||
// Two-sided: a point just outside a bound can beat the optimum by far more than the
|
||||
// precision when its costs are large.
|
||||
rows_hold && bounds_hold && near((value - best).abs(), size + best.abs())
|
||||
}
|
||||
|
||||
fn entry(tab: &FinalTableau, r: usize, col: usize) -> f64 {
|
||||
tab.t.get(r).and_then(|row| row.get(col)).copied().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
fn real_basic(tab: &FinalTableau, r: usize) -> bool {
|
||||
tab.basis.get(r).is_some_and(|b| *b < tab.first_art)
|
||||
}
|
||||
|
||||
/// -1 for a row the tableau negated, else 1.
|
||||
fn flip(tab: &FinalTableau, i: usize) -> f64 {
|
||||
if tab.flipped.get(i).copied().unwrap_or(false) { -1.0 } else { 1.0 }
|
||||
}
|
||||
|
||||
/// Central-difference gradients, inside the bounds, of the objective and of each row's
|
||||
/// `lhs − rhs` at the solution.
|
||||
fn gradients(ev: &mut Evaluator, inp: &ReportInput) -> Result<(Vec<f64>, Vec<Vec<f64>>)> {
|
||||
let x = &inp.solution;
|
||||
let n = x.len();
|
||||
let mut df = vec![0.0; n];
|
||||
let mut dg = vec![vec![0.0; n]; ev.rows.len()];
|
||||
for j in 0..n {
|
||||
let xj = x.get(j).copied().unwrap_or(0.0);
|
||||
let (l, u) = (inp.prob.lo.get(j).copied().unwrap_or(f64::NEG_INFINITY), inp.prob.hi.get(j).copied().unwrap_or(f64::INFINITY));
|
||||
let h = 1e-6 * xj.abs().max(1.0);
|
||||
let (a, b) = ((xj - h).max(l), (xj + h).min(u));
|
||||
if b <= a {
|
||||
continue;
|
||||
}
|
||||
let mut point = |v: f64| {
|
||||
let mut p = x.clone();
|
||||
if let Some(e) = p.get_mut(j) {
|
||||
*e = v;
|
||||
}
|
||||
ev.eval(&p).ok_or_else(out_of_evaluations)
|
||||
};
|
||||
let (pa, pb) = (point(a)?, point(b)?);
|
||||
if let Some(e) = df.get_mut(j) {
|
||||
*e = (pb.f - pa.f) / (b - a);
|
||||
}
|
||||
for (row, (ga, gb)) in dg.iter_mut().zip(pa.g.iter().zip(&pb.g)) {
|
||||
if let Some(e) = row.get_mut(j) {
|
||||
*e = (gb - ga) / (b - a);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((df, dg))
|
||||
}
|
||||
|
||||
/// The rounding noise in the Lagrange multiplier of row `i`, one of the `active` rows whose
|
||||
/// multipliers `lambda` solve the normal equations of ∇f = Σ λₖ ∇gₖ over the `free` variables.
|
||||
/// Row i's equation gives λᵢ = (Σⱼ gᵢⱼ·dfⱼ − Σ_{k≠i} λₖ·Σⱼ gᵢⱼ·gₖⱼ) / Σⱼ gᵢⱼ²; the difference
|
||||
/// gradients are good to about 1E-6 of each component, and the rounding error of a sum scales
|
||||
/// with the sum of its terms' sizes, so the noise is 1E-6 of the terms' sizes over the row's
|
||||
/// own squared norm. The bound is local to the row: another row enters only through its
|
||||
/// coupling term with this one, so a row whose gradient is orthogonal to the others has a bound
|
||||
/// from its own gradient alone, and a large component of ∇f that the row's gradient doesn't
|
||||
/// touch hides nothing. Nearly parallel active gradients make the normal equations
|
||||
/// ill-conditioned and amplify every multiplier's error beyond any bound local to a row; such
|
||||
/// multipliers are ill-determined whatever the bound, and they print as computed.
|
||||
fn multiplier_noise(i: usize, active: &[usize], lambda: &[f64], free: &[usize], (df, dg): (&[f64], &[Vec<f64>])) -> f64 {
|
||||
let grad = |i: usize, j: usize| dg.get(i).and_then(|r| r.get(j)).copied().unwrap_or(0.0);
|
||||
let own: f64 = free.iter().map(|&j| grad(i, j) * grad(i, j)).sum();
|
||||
let objective: f64 = free.iter().map(|&j| (grad(i, j) * df.get(j).copied().unwrap_or(0.0)).abs()).sum();
|
||||
let coupling: f64 = active
|
||||
.iter()
|
||||
.zip(lambda)
|
||||
.filter(|(k, _)| **k != i)
|
||||
.map(|(&k, l)| free.iter().map(|&j| (grad(i, j) * grad(k, j) * l).abs()).sum::<f64>())
|
||||
.sum();
|
||||
1e-6 * (objective + coupling) / own.max(f64::MIN_POSITIVE)
|
||||
}
|
||||
|
||||
/// Least squares on the free variables: ∇f = Σ λᵢ ∇gᵢ over the `active` rows. An inequality's
|
||||
/// multiplier has the sign that tightening it costs (λ ≥ 0 for `lhs ≤ rhs` when maximising); a
|
||||
/// row whose multiplier has the other sign beyond its own noise ([`multiplier_noise`], the
|
||||
/// criterion the report rounds it with) isn't binding, so it leaves and the rest are solved
|
||||
/// again; the objective's value plays no part. Returns each row's multiplier and its noise (0
|
||||
/// and 0 for a row that doesn't bind). The work is bounded by `budget` (see
|
||||
/// [`MULTIPLIER_WORK`]); past it the report is refused.
|
||||
fn multipliers(
|
||||
rows: &[Row],
|
||||
goal: Goal,
|
||||
mut active: Vec<usize>,
|
||||
free: &[usize],
|
||||
(df, dg): (&[f64], &[Vec<f64>]),
|
||||
budget: u64,
|
||||
) -> Result<(Vec<f64>, Vec<f64>)> {
|
||||
let grad = |i: usize, j: usize| dg.get(i).and_then(|r| r.get(j)).copied().unwrap_or(0.0);
|
||||
let goal_sign = if goal == Goal::Max { 1.0 } else { -1.0 };
|
||||
let mut lambda = vec![0.0; rows.len()];
|
||||
let mut noise = vec![0.0; rows.len()];
|
||||
let mut work = 0u64;
|
||||
while !active.is_empty() {
|
||||
let (k, f) = (active.len() as u64, (free.len() as u64).max(1));
|
||||
work = work.saturating_add(3u64.saturating_mul(k).saturating_mul(k).saturating_mul(f));
|
||||
if work > budget {
|
||||
return Err(EngineError::Other(
|
||||
"The Sensitivity report of this nonlinear model has too many binding constraints to work out in reasonable time.".into(),
|
||||
));
|
||||
}
|
||||
let normal: Vec<Vec<f64>> =
|
||||
active.iter().map(|&i| active.iter().map(|&k| free.iter().map(|&j| grad(i, j) * grad(k, j)).sum()).collect()).collect();
|
||||
let rhs: Vec<f64> = active.iter().map(|&i| free.iter().map(|&j| grad(i, j) * df.get(j).copied().unwrap_or(0.0)).sum()).collect();
|
||||
let solved = solve_dense(normal, rhs);
|
||||
let bounds: Vec<f64> = active.iter().map(|&i| multiplier_noise(i, &active, &solved, free, (df, dg))).collect();
|
||||
let wrong = active
|
||||
.iter()
|
||||
.zip(&solved)
|
||||
.zip(&bounds)
|
||||
.enumerate()
|
||||
.filter_map(|(k, ((&i, l), bound))| {
|
||||
let want = match rows.get(i).map_or(Sense::Eq, |r| r.sense) {
|
||||
Sense::Le => goal_sign,
|
||||
Sense::Ge => -goal_sign,
|
||||
Sense::Eq => return None,
|
||||
};
|
||||
(want * l < -bound).then_some((k, want * l))
|
||||
})
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1));
|
||||
match wrong {
|
||||
Some((k, _)) => {
|
||||
active.remove(k);
|
||||
}
|
||||
None => {
|
||||
for ((i, l), bound) in active.iter().zip(solved).zip(bounds) {
|
||||
if let (Some(e), Some(n)) = (lambda.get_mut(*i), noise.get_mut(*i)) {
|
||||
*e = l;
|
||||
*n = bound;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((lambda, noise))
|
||||
}
|
||||
|
||||
/// Whether the solution is a Karush-Kuhn-Tucker point of the model, from the gradients the
|
||||
/// report computed: every row and bound holds, and each reduced gradient (the objective's
|
||||
/// gradient less what the binding rows' multipliers explain) is near zero relative to `terms`,
|
||||
/// the sizes of the terms it is made of, or, for a variable at a bound, points only out of that
|
||||
/// bound in the objective's direction. A binding row whose multiplier had the wrong sign was
|
||||
/// dropped by [`multipliers`], so the part of the gradient it would have explained remains in
|
||||
/// the reduced gradients and the point is refused here. Every tolerance is local to the row or
|
||||
/// variable it tests: a large right side or gradient component elsewhere in the model excuses
|
||||
/// nothing here.
|
||||
fn kkt_point(at: &At, rows: &[Row], terms: &[f64], reduced: &[f64]) -> bool {
|
||||
let (inp, x) = (at.inp, &at.inp.solution);
|
||||
// Feasibility: each row within ten times the constraint precision of its own right side.
|
||||
// The nonlinear method accepts a solution within that of the model's largest constant right
|
||||
// side, which lets a small row go by its own size; a row over by more than its own
|
||||
// precision isn't satisfied, whatever the other rows' sizes, so the report refuses it.
|
||||
let rows_hold = rows.iter().enumerate().all(|(i, r)| {
|
||||
let (g, b) = (at.point.g.get(i).copied().unwrap_or(f64::NAN), at.finals.get(i).map_or(f64::NAN, |f| f.1));
|
||||
let over = match r.sense {
|
||||
Sense::Le => g,
|
||||
Sense::Ge => -g,
|
||||
Sense::Eq => g.abs(),
|
||||
};
|
||||
over <= 10.0 * super::PRECISION * (1.0 + b.abs())
|
||||
});
|
||||
if !rows_hold || !at.point.f.is_finite() {
|
||||
return false;
|
||||
}
|
||||
// Stationarity: each reduced gradient relative to the terms it is made of (the objective's
|
||||
// component and the multipliers' parts, at least 1). At optima the nonlinear method solves,
|
||||
// a reduced gradient is below about 1E-6 of those terms; 1E-3 leaves a wide margin for the
|
||||
// method's stopping test and for rounding in the difference gradients of badly scaled
|
||||
// models, and still refuses a point whose objective improves visibly along a direction the
|
||||
// constraints allow. The bound is the variable's own: with a bound from the gradient's
|
||||
// largest component, max 1E9·x + y at (1, 0.5) passed, although raising y gains 1 per unit
|
||||
// against a tolerance of 1E6.
|
||||
// Moving up a variable improves a maximum by +r and a minimum by −r per unit.
|
||||
let sign = if inp.model.goal == Goal::Max { 1.0 } else { -1.0 };
|
||||
x.iter().enumerate().all(|(j, &v)| {
|
||||
let (l, h) = (inp.prob.lo.get(j).copied().unwrap_or(f64::NEG_INFINITY), inp.prob.hi.get(j).copied().unwrap_or(f64::INFINITY));
|
||||
let near = super::PRECISION * (1.0 + v.abs());
|
||||
if !v.is_finite() || v < l - near || v > h + near {
|
||||
return false;
|
||||
}
|
||||
// The same test for "at a bound" as the choice of free variables above.
|
||||
let edge = 1e-6 * v.abs().max(1.0);
|
||||
let (at_lo, at_hi) = (v <= l + edge, v >= h - edge);
|
||||
let gain = reduced.get(j).map_or(f64::NAN, |r| sign * r);
|
||||
let tol = 1e-3 * terms.get(j).copied().unwrap_or(0.0).max(1.0);
|
||||
gain.is_finite() && (at_hi || gain <= tol) && (at_lo || gain >= -tol)
|
||||
})
|
||||
}
|
||||
|
||||
/// GRG Nonlinear: reduced gradients and the binding rows' Lagrange multipliers.
|
||||
fn nonlinear_body(ev: &mut Evaluator, at: &At) -> Result<Body> {
|
||||
let (inp, m) = (at.inp, at.inp.model);
|
||||
let x = &inp.solution;
|
||||
let (df, dg) = gradients(ev, inp)?;
|
||||
// Binding rows (within the nonlinear method's accuracy) and the variables strictly inside
|
||||
// their bounds.
|
||||
let active: Vec<usize> = ev
|
||||
.rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, r)| {
|
||||
let (g, b) = (at.point.g.get(*i).copied().unwrap_or(f64::NAN), at.finals.get(*i).map_or(0.0, |f| f.1));
|
||||
r.sense == Sense::Eq || g.abs() <= 1e-4 * (1.0 + b.abs())
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
let free: Vec<usize> = (0..x.len())
|
||||
.filter(|&j| {
|
||||
let v = x.get(j).copied().unwrap_or(0.0);
|
||||
let tol = 1e-6 * v.abs().max(1.0);
|
||||
v > inp.prob.lo.get(j).copied().unwrap_or(f64::NEG_INFINITY) + tol && v < inp.prob.hi.get(j).copied().unwrap_or(f64::INFINITY) - tol
|
||||
})
|
||||
.collect();
|
||||
let (lambda, lambda_noise) = multipliers(&ev.rows, m.goal, active, &free, (&df, &dg), MULTIPLIER_WORK)?;
|
||||
let grad = |i: usize, j: usize| dg.get(i).and_then(|r| r.get(j)).copied().unwrap_or(0.0);
|
||||
let reduced: Vec<f64> =
|
||||
(0..x.len()).map(|j| df.get(j).copied().unwrap_or(0.0) - lambda.iter().enumerate().map(|(i, l)| l * grad(i, j)).sum::<f64>()).collect();
|
||||
// A reduced gradient dfⱼ − Σᵢ λᵢ·gᵢⱼ is a sum of the gradient's own component and the
|
||||
// multipliers' terms; the sizes of those terms bound its rounding error and scale the test
|
||||
// of whether it is zero.
|
||||
let terms: Vec<f64> = (0..x.len())
|
||||
.map(|j| df.get(j).copied().unwrap_or(0.0).abs() + lambda.iter().enumerate().map(|(i, l)| (l * grad(i, j)).abs()).sum::<f64>())
|
||||
.collect();
|
||||
// The report describes the optimum, so the values it shows must be one.
|
||||
if !kkt_point(at, &ev.rows, &terms, &reduced) {
|
||||
return Err(not_optimal());
|
||||
}
|
||||
// Difference gradients are good to about 1E-6 of each component, so a reduced gradient's
|
||||
// noise is 1E-6 of its terms' sizes, plus each multiplier's own noise
|
||||
// ([`multiplier_noise`]) times |gᵢⱼ|. The bound is the variable's own: a small gradient
|
||||
// keeps its value beside a large component of another variable.
|
||||
let reduced_noise = |j: usize| {
|
||||
let carried: f64 = lambda_noise.iter().enumerate().map(|(i, n)| if grad(i, j) == 0.0 { 0.0 } else { n * grad(i, j).abs() }).sum();
|
||||
1e-6 * terms.get(j).copied().unwrap_or(0.0) + carried
|
||||
};
|
||||
let var_rows = m
|
||||
.vars
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(j, c)| {
|
||||
let mut row = at.var_head(j, *c);
|
||||
row.push(sens_num(reduced.get(j).copied().unwrap_or(f64::NAN), reduced_noise(j)));
|
||||
row
|
||||
})
|
||||
.collect();
|
||||
let con_rows = ev
|
||||
.rows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, r)| {
|
||||
let mut row = at.row_head(i, r);
|
||||
row.push(sens_num(lambda.get(i).copied().unwrap_or(0.0), lambda_noise.get(i).copied().unwrap_or(0.0)));
|
||||
row
|
||||
})
|
||||
.collect();
|
||||
Ok((var_rows, con_rows, vec!["Cell", "Name", "Final Value", "Reduced Gradient"], vec!["Cell", "Name", "Final Value", "Lagrange Multiplier"]))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Goal, MULTIPLIER_WORK, Row, Sense, multipliers};
|
||||
use gridcraft_core::CellRef;
|
||||
|
||||
#[test]
|
||||
fn multipliers_at_the_constraint_limit_stop_at_the_work_limit() {
|
||||
// 1,000 binding `<=` rows over 200 free variables, each multiplier with the wrong sign:
|
||||
// without a limit every pass would drop one row and solve again, about 4·10⁸ operations
|
||||
// a pass for up to 1,000 passes. The limit (lowered in tests) refuses it at once.
|
||||
let (k, f) = (1000usize, 200usize);
|
||||
let rows: Vec<Row> = (0..k).map(|_| Row { lhs: CellRef::new(0, 0), sense: Sense::Le, rhs: None, rhs_const: 0.0 }).collect();
|
||||
let dg: Vec<Vec<f64>> = (0..k).map(|i| (0..f).map(|j| if j == i % f { 1.0 } else { 0.0 }).collect()).collect();
|
||||
let df = vec![-1.0; f];
|
||||
let free: Vec<usize> = (0..f).collect();
|
||||
let got = multipliers(&rows, Goal::Max, (0..k).collect(), &free, (&df, &dg), MULTIPLIER_WORK);
|
||||
assert!(got.is_err());
|
||||
// A small model within the limit still gets its multipliers: ∇f = (1, 2) on two
|
||||
// binding rows with unit gradients.
|
||||
let dg2 = vec![vec![1.0, 0.0], vec![0.0, 1.0]];
|
||||
let (got, noise) = multipliers(&rows[..2], Goal::Max, vec![0, 1], &free[..2], (&[1.0, 2.0], &dg2), MULTIPLIER_WORK).unwrap();
|
||||
assert_eq!(got, vec![1.0, 2.0]);
|
||||
// Each multiplier's noise is 1E-6 of its own row's term (1 · 1 and 1 · 2 over a unit
|
||||
// norm): the rows are orthogonal, so neither enters the other's bound.
|
||||
assert_eq!(noise, vec![1e-6, 2e-6]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -213,7 +213,7 @@ impl Trial {
|
||||
for (si, c, v) in sets {
|
||||
if let Some(sh) = wb.sheet_mut(*si) {
|
||||
let style = sh.style_id(*c);
|
||||
sh.set_cell(*c, Cell { value: v.clone(), formula: None, style });
|
||||
sh.set_cell(*c, Cell { value: v.clone(), formula: None, style, py: None });
|
||||
keys.push((*si, *c));
|
||||
}
|
||||
}
|
||||
@@ -329,7 +329,8 @@ fn goal_seek(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if !has_formula(ssi, set) {
|
||||
return Err(EngineError::Other("Cell must contain a formula.".into()));
|
||||
}
|
||||
if has_formula(csi, chg) {
|
||||
// A Python cell is a formula to Excel (`=PY(…)`): writing a number over it would lose its code.
|
||||
if has_formula(csi, chg) || d.wb.sheet(csi).and_then(|sh| sh.cell(chg)).is_some_and(|c| c.py.is_some()) {
|
||||
return Err(EngineError::Other("Cell must contain a value.".into()));
|
||||
}
|
||||
let x0 = d.wb.sheet(csi).map(|sh| sh.value(chg)).and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
@@ -342,7 +343,7 @@ fn goal_seek(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(csi)?;
|
||||
let style = sh.style_id(chg);
|
||||
sh.set_cell(chg, Cell { value: Value::number(r.x), formula: None, style });
|
||||
sh.set_cell(chg, Cell { value: Value::number(r.x), formula: None, style, py: None });
|
||||
cx.touch(csi, chg);
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -422,7 +423,7 @@ fn data_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
for (c, v) in out {
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
let style = sh.style_id(c);
|
||||
sh.set_cell(c, Cell { value: v, formula: None, style });
|
||||
sh.set_cell(c, Cell { value: v, formula: None, style, py: None });
|
||||
cx.touch(si, c);
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -186,7 +186,7 @@ pub(super) fn advanced_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
// The full output footprint was checked above.
|
||||
let to = CellRef::new(dest.row + i as u32, dest.col + c - list.start.col);
|
||||
let style = sh.style_id(src);
|
||||
(to, Cell { value: sh.value(src), formula: None, style })
|
||||
(to, Cell { value: sh.value(src), formula: None, style, py: None })
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -185,7 +185,7 @@ pub(super) fn consolidate(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if v.is_empty() && style == StyleId::DEFAULT {
|
||||
sh.remove_cell(c);
|
||||
} else {
|
||||
sh.set_cell(c, Cell { value: v, formula: None, style });
|
||||
sh.set_cell(c, Cell { value: v, formula: None, style, py: None });
|
||||
}
|
||||
cx.touch(dsi, c);
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ pub(super) fn scenario_show(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let cell = match v.as_f64() {
|
||||
Some(n) => {
|
||||
let style = cx.wb.sheet(si).map(|sh| sh.style_id(*c)).unwrap_or_default();
|
||||
Some(Cell { value: Value::Number(n), formula: None, style })
|
||||
Some(Cell { value: Value::Number(n), formula: None, style, py: None })
|
||||
}
|
||||
None => crate::cmd::edit::input_to_cell(&crate::cmd::edit::json_to_input(v), si, *c, &mut cx.wb)?,
|
||||
};
|
||||
@@ -245,7 +245,7 @@ pub(super) fn scenario_summary(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let bold = cx.wb.styles.derive(StyleId::DEFAULT, |st| st.font.bold = true);
|
||||
let mut sheet = Sheet::new(name.clone());
|
||||
let put = |sheet: &mut Sheet, row: u32, col: u32, v: Value, style: StyleId| {
|
||||
sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style });
|
||||
sheet.set_cell(CellRef::new(row, col), Cell { value: v, formula: None, style, py: None });
|
||||
};
|
||||
put(&mut sheet, 1, 1, Value::text("Scenario Summary"), bold);
|
||||
put(&mut sheet, 2, 2, Value::text("Current Values:"), bold);
|
||||
|
||||
@@ -90,14 +90,14 @@ pub fn edit_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String {
|
||||
if reads_back(&iso) { iso } else { raw }
|
||||
}
|
||||
|
||||
/// Whether `c` has a formula that must not be shown: its cell is formatted Hidden on a
|
||||
/// protected sheet. A spilled cell follows its anchor.
|
||||
/// Whether `c` has a formula or Python code that must not be shown: its cell is formatted
|
||||
/// Hidden on a protected sheet. A spilled cell follows its anchor.
|
||||
pub fn formula_hidden(wb: &Workbook, sheet: &Sheet, c: CellRef) -> bool {
|
||||
if !sheet.is_protected() {
|
||||
return false;
|
||||
}
|
||||
let at = match sheet.cell(c) {
|
||||
Some(x) if x.formula.is_some() => c,
|
||||
Some(x) if x.formula.is_some() || x.py.is_some() => c,
|
||||
_ => match sheet.spill_ranges.iter().find(|(_, r)| r.contains(c)) {
|
||||
Some((anchor, _)) => *anchor,
|
||||
None => return false,
|
||||
|
||||
@@ -171,10 +171,12 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
|
||||
cell.formula = Some(Arc::new(Formula::from_expr(shifted)));
|
||||
cell.value = Value::Empty;
|
||||
}
|
||||
} else if let Some(v) = match per_cell.get(src_i) {
|
||||
Some(p) => p.value(k as i64 / src_cells.len() as i64 + 1, forward, 1),
|
||||
None => pattern.value(step, forward, src_cells.len()),
|
||||
} {
|
||||
} else if cell.py.is_none()
|
||||
&& let Some(v) = match per_cell.get(src_i) {
|
||||
Some(p) => p.value(k as i64 / src_cells.len() as i64 + 1, forward, 1),
|
||||
None => pattern.value(step, forward, src_cells.len()),
|
||||
}
|
||||
{
|
||||
cell.value = v;
|
||||
}
|
||||
cell.style = style;
|
||||
@@ -296,7 +298,8 @@ impl Pattern {
|
||||
|
||||
fn detect(cells: &[(CellRef, Option<Cell>)], custom: &[Vec<String>], sys: gridcraft_core::DateSystem, fmt: &str) -> Pattern {
|
||||
let vals: Vec<Value> = cells.iter().map(|(_, c)| c.as_ref().map(|c| c.value.clone()).unwrap_or_default()).collect();
|
||||
if cells.iter().any(|(_, c)| c.as_ref().is_some_and(|c| c.formula.is_some())) {
|
||||
// Formulas and Python cells copy.
|
||||
if cells.iter().any(|(_, c)| c.as_ref().is_some_and(|c| c.formula.is_some() || c.py.is_some())) {
|
||||
return Pattern::Copy;
|
||||
}
|
||||
// Numbers.
|
||||
@@ -598,7 +601,7 @@ pub fn series(cx: &mut Ctx, sheet: usize, options: SeriesOptions<'_>) -> Result<
|
||||
break;
|
||||
}
|
||||
let c = if rows { CellRef::new(lane, r.start.col + i) } else { CellRef::new(r.start.row + i, lane) };
|
||||
writes.push((c, Cell { value: Value::number(round15(v)), formula: None, style }));
|
||||
writes.push((c, Cell { value: Value::number(round15(v)), formula: None, style, py: None }));
|
||||
}
|
||||
}
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
@@ -657,7 +660,7 @@ pub fn flash_fill(cx: &mut Ctx, sheet: usize, at: CellRef) -> Result<usize> {
|
||||
}
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
for (c, out) in &writes {
|
||||
sh.set_cell(*c, Cell { value: Value::text(out.as_str()), formula: None, style });
|
||||
sh.set_cell(*c, Cell { value: Value::text(out.as_str()), formula: None, style, py: None });
|
||||
}
|
||||
for (c, _) in writes {
|
||||
cx.touch(sheet, c);
|
||||
|
||||
+130
-1
@@ -118,13 +118,142 @@ fn decrypt_message(name: &str, e: gridcraft_xlsx::DecryptError) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses bytes that aren't encrypted, then takes the workbook's macros out of their hidden names.
|
||||
/// Parses bytes that aren't encrypted, then takes the workbook's macros out of their hidden names
|
||||
/// and adopts the Python cells they hold (see [`adopt_python`]) within the limits of
|
||||
/// [`enforce_python_limits`].
|
||||
fn open_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, Vec<String>)> {
|
||||
let (mut wb, mut warnings) = read_plain(name, bytes, csv_as_text)?;
|
||||
warnings.extend(crate::macros::load(&mut wb));
|
||||
warnings.extend(adopt_python(&mut wb));
|
||||
warnings.extend(enforce_python_limits(&mut wb));
|
||||
Ok((wb, warnings))
|
||||
}
|
||||
|
||||
/// The most result cells [`adopt_python`] rebuilds from a file's cached spills, over all its
|
||||
/// Python cells (each one is also limited to [`gridcraft_model::python::MAX_RESULT_CELLS`]).
|
||||
pub const MAX_ADOPTED_RESULT_CELLS: u64 = 4_000_000;
|
||||
|
||||
/// Python cells from a file: a cell whose formula is `PY("…",0|1)` (a format that kept it as a
|
||||
/// formula) becomes a Python cell, and a Python cell whose spilled values the file cached gets
|
||||
/// them back as its result, so it shows them until it runs again. Results are rebuilt for at most
|
||||
/// [`MAX_ADOPTED_RESULT_CELLS`] cells in all; the rest keep only their own cached value, and the
|
||||
/// values they spilled stay as plain values. Returns the warnings.
|
||||
pub fn adopt_python(wb: &mut Workbook) -> Vec<String> {
|
||||
let mut budget = MAX_ADOPTED_RESULT_CELLS;
|
||||
let mut skipped = 0usize;
|
||||
for si in 0..wb.sheets.len() {
|
||||
let Some(sh) = wb.sheets.get(si) else { continue };
|
||||
let formulas: Vec<(CellRef, gridcraft_model::PyCode)> = sh
|
||||
.cells
|
||||
.iter()
|
||||
.filter_map(|(c, cell)| cell.formula.as_ref().and_then(|f| gridcraft_model::PyCode::from_formula(&f.text)).map(|p| (c, p)))
|
||||
.collect();
|
||||
let max = gridcraft_model::python::MAX_RESULT_CELLS as u64;
|
||||
let spills: Vec<(CellRef, RangeRef)> = sh
|
||||
.spill_ranges
|
||||
.iter()
|
||||
.filter(|(a, r)| r.count() <= max && sh.cell(**a).and_then(|c| c.py.as_ref()).is_some_and(|p| p.output.is_none()))
|
||||
.map(|(a, r)| (*a, *r))
|
||||
.collect();
|
||||
if formulas.is_empty() && spills.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(sh) = wb.sheet_mut(si) else { continue };
|
||||
for (c, py) in formulas {
|
||||
if let Some(cell) = sh.cells.get_mut(c) {
|
||||
cell.formula = None;
|
||||
cell.py = Some(std::sync::Arc::new(py));
|
||||
}
|
||||
}
|
||||
for (anchor, range) in spills {
|
||||
let Some(left) = budget.checked_sub(range.count()) else {
|
||||
skipped += 1;
|
||||
// The cell keeps only its own value; the values it spilled stay as plain values, so
|
||||
// nothing shows as a spill that no result holds, and nothing the file showed is lost.
|
||||
sh.settle_spill(anchor);
|
||||
continue;
|
||||
};
|
||||
budget = left;
|
||||
let data: Vec<gridcraft_core::Value> =
|
||||
range.iter().map(|c| if c == anchor { sh.cell(c).map(|x| x.value.clone()).unwrap_or_default() } else { sh.value(c) }).collect();
|
||||
let Some(array) = gridcraft_core::Array::new(range.height() as usize, range.width() as usize, data) else { continue };
|
||||
if let Some(py) = sh.cells.get_mut(anchor).and_then(|x| x.py.as_mut()) {
|
||||
std::sync::Arc::make_mut(py).output =
|
||||
Some(gridcraft_model::PyOutput { value: array.into(), type_name: "list".into(), preview: String::new(), convertible: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
if skipped == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![format!(
|
||||
"The file's Python results are larger than {MAX_ADOPTED_RESULT_CELLS} cells in all, so {skipped} Python cell(s) have no result until they run again; the values they spilled were kept as plain values."
|
||||
)]
|
||||
}
|
||||
|
||||
/// A file is somebody else's work: a Python cell whose code doesn't fit in Excel's
|
||||
/// 8,192-character formula keeps only its cached value, and a kept result that is not a
|
||||
/// well-formed array of at most [`gridcraft_model::python::MAX_RESULT_CELLS`] cells is dropped
|
||||
/// (the cell keeps its value). Either way the values the cell spilled stay as plain values
|
||||
/// ([`gridcraft_model::Sheet::settle_spill`]). Returns the warnings.
|
||||
pub fn enforce_python_limits(wb: &mut Workbook) -> Vec<String> {
|
||||
let mut long = Vec::new();
|
||||
for si in 0..wb.sheets.len() {
|
||||
let Some(sh) = wb.sheets.get(si) else { continue };
|
||||
let mut over = Vec::new();
|
||||
let mut bad_output = Vec::new();
|
||||
for (c, cell) in sh.cells.iter() {
|
||||
let Some(py) = &cell.py else { continue };
|
||||
if !py.fits() {
|
||||
long.push(format!("{}!{}", sh.name, c.a1()));
|
||||
over.push(c);
|
||||
} else if py.output.as_ref().is_some_and(|o| !sane_python_output(&o.value)) {
|
||||
bad_output.push(c);
|
||||
}
|
||||
}
|
||||
if over.is_empty() && bad_output.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let dropped: Vec<CellRef> = over.iter().chain(&bad_output).copied().collect();
|
||||
let Some(sh) = wb.sheet_mut(si) else { continue };
|
||||
for c in over {
|
||||
if let Some(cell) = sh.cells.get_mut(c) {
|
||||
cell.py = None;
|
||||
}
|
||||
}
|
||||
for c in bad_output {
|
||||
if let Some(py) = sh.cells.get_mut(c).and_then(|x| x.py.as_mut()) {
|
||||
std::sync::Arc::make_mut(py).output = None;
|
||||
}
|
||||
}
|
||||
// A dropped result (or code dropped to its value) leaves the values it spilled as plain
|
||||
// values, as the file showed them, with no live spill.
|
||||
for c in dropped {
|
||||
sh.settle_spill(c);
|
||||
}
|
||||
}
|
||||
let Some(first) = long.first() else { return vec![] };
|
||||
let more = if long.len() > 1 { format!(" (and {} more)", long.len() - 1) } else { String::new() };
|
||||
vec![format!(
|
||||
"A Python cell's code is longer than Excel's {}-character formula limit, so {first}{more} kept only its last calculated value.",
|
||||
gridcraft_model::python::MAX_FORMULA_CHARS
|
||||
)]
|
||||
}
|
||||
|
||||
/// A Python result a file may hold: a scalar, or an array whose size matches its data and has at
|
||||
/// most [`gridcraft_model::python::MAX_RESULT_CELLS`] cells.
|
||||
fn sane_python_output(v: &gridcraft_core::Value) -> bool {
|
||||
match v {
|
||||
gridcraft_core::Value::Array(a) => {
|
||||
a.rows >= 1
|
||||
&& a.cols >= 1
|
||||
&& a.rows.checked_mul(a.cols).is_some_and(|n| n == a.data.len() && n <= gridcraft_model::python::MAX_RESULT_CELLS)
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses bytes that aren't encrypted, as they are.
|
||||
fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook, Vec<String>)> {
|
||||
let kind = FileKind::from_path(name);
|
||||
let sniffed = gridcraft_xlsx::sniff(bytes);
|
||||
|
||||
@@ -461,17 +461,17 @@ fn csv_writes_displayed_text() {
|
||||
(styled("yyyy-mm-dd"), styled("$#,##0.00"), styled("0.0%"), styled("# ?/?"), styled("@"), styled("0.00"), styled("#,##0"));
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
let cells = [
|
||||
("A1", Cell { value: Value::Number(46023.0), formula: None, style: date }),
|
||||
("B1", Cell { value: Value::Number(1234.5), formula: None, style: money }),
|
||||
("C1", Cell { value: Value::Number(0.125), formula: None, style: pct }),
|
||||
("D1", Cell { value: Value::Number(1.5), formula: None, style: frac }),
|
||||
("E1", Cell { value: Value::text("007"), formula: None, style: text }),
|
||||
("F1", Cell { value: Value::Error(CellError::NA), formula: None, style: money }),
|
||||
("G1", Cell { value: Value::Bool(true), formula: None, style: StyleId::DEFAULT }),
|
||||
("A1", Cell { value: Value::Number(46023.0), formula: None, style: date, py: None }),
|
||||
("B1", Cell { value: Value::Number(1234.5), formula: None, style: money, py: None }),
|
||||
("C1", Cell { value: Value::Number(0.125), formula: None, style: pct, py: None }),
|
||||
("D1", Cell { value: Value::Number(1.5), formula: None, style: frac, py: None }),
|
||||
("E1", Cell { value: Value::text("007"), formula: None, style: text, py: None }),
|
||||
("F1", Cell { value: Value::Error(CellError::NA), formula: None, style: money, py: None }),
|
||||
("G1", Cell { value: Value::Bool(true), formula: None, style: StyleId::DEFAULT, py: None }),
|
||||
("A2", Cell { value: Value::Number(46024.0), style: date, ..Cell::formula(Formula::new("A1+1")) }),
|
||||
("B2", Cell { value: Value::Number(12345678.9), formula: None, style: fixed }),
|
||||
("C2", Cell { value: Value::Number(-0.25), formula: None, style: StyleId::DEFAULT }),
|
||||
("D2", Cell { value: Value::Number(1234567.0), formula: None, style: thousands }),
|
||||
("B2", Cell { value: Value::Number(12345678.9), formula: None, style: fixed, py: None }),
|
||||
("C2", Cell { value: Value::Number(-0.25), formula: None, style: StyleId::DEFAULT, py: None }),
|
||||
("D2", Cell { value: Value::Number(1234567.0), formula: None, style: thousands, py: None }),
|
||||
];
|
||||
for (a, c) in cells {
|
||||
sh.set_cell(CellRef::parse(a).unwrap(), c);
|
||||
|
||||
@@ -39,6 +39,7 @@ pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command, register_te
|
||||
pub use gridcraft_calc as calc;
|
||||
pub use gridcraft_core as core;
|
||||
pub use gridcraft_formula as formula;
|
||||
pub use gridcraft_math as math;
|
||||
pub use gridcraft_model as model;
|
||||
pub use gridcraft_numfmt as numfmt;
|
||||
pub use selection::Selection;
|
||||
@@ -304,7 +305,9 @@ impl DocState {
|
||||
calc.rebuild(&wb);
|
||||
Self::with_calc(wb, calc, path, title, trust)
|
||||
}
|
||||
fn with_calc(wb: Workbook, calc: Calc, path: Option<String>, title: String, trust: Trust) -> DocState {
|
||||
fn with_calc(mut wb: Workbook, calc: Calc, path: Option<String>, title: String, trust: Trust) -> DocState {
|
||||
// Equations arrive without a layout (files never store one); painting reads only the cache.
|
||||
wb.refresh_equations();
|
||||
let scripts_trusted = !cmd::extra::has_scripts(&wb);
|
||||
let wb = Arc::new(wb);
|
||||
let sel = Selection::at(wb.active().map(|s| s.view_active).unwrap_or_default());
|
||||
@@ -792,7 +795,7 @@ impl Session {
|
||||
// A toggle (Bold) is recorded with the state it set, as Excel records
|
||||
// `Bold = True`: playing it back sets that state rather than flipping it again.
|
||||
let params = match &result {
|
||||
Ok(r) if params.is_object() => record::with_toggle_state(cmd::journal_params(¶ms), r),
|
||||
Ok(r) if params.is_object() => record::with_toggle_state(id, cmd::journal_params(¶ms), r),
|
||||
_ => cmd::journal_params(¶ms),
|
||||
};
|
||||
let kept = rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params });
|
||||
|
||||
@@ -614,7 +614,10 @@ mod tests {
|
||||
let mut wb = wb();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
let formula = Some(std::sync::Arc::new(gridcraft_model::Formula::new("\"Amo\"&\"unt\"")));
|
||||
sh.set_cell(CellRef::new(1, 2), gridcraft_model::Cell { value: gridcraft_core::Value::text("Amount"), formula, style: Default::default() });
|
||||
sh.set_cell(
|
||||
CellRef::new(1, 2),
|
||||
gridcraft_model::Cell { value: gridcraft_core::Value::text("Amount"), formula, style: Default::default(), py: None },
|
||||
);
|
||||
sh.show_formulas = true;
|
||||
let inputs = vec![("Input".to_string(), place("B2:D11"), true)];
|
||||
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None };
|
||||
|
||||
@@ -1237,26 +1237,39 @@ pub(crate) fn pair_sheets(before: &Workbook, after: &Workbook) -> (Vec<(usize, O
|
||||
(pairs, spare.collect())
|
||||
}
|
||||
|
||||
/// Only a formula's cached value changed (a recalculation, not a write).
|
||||
/// Only a formula's (or a Python cell's) cached value changed (a recalculation, not a write): a
|
||||
/// Python cell whose spill a step blocked shows #SPILL! with its code untouched.
|
||||
fn recalculated_only(b: Option<&Cell>, a: Option<&Cell>) -> bool {
|
||||
match (b, a) {
|
||||
(Some(b), Some(a)) => match (&b.formula, &a.formula) {
|
||||
(Some(x), Some(y)) => x.text == y.text && x.array == y.array && b.style == a.style,
|
||||
(None, None) => match (&b.py, &a.py) {
|
||||
(Some(x), Some(y)) => x.code == y.code && x.object == y.object && b.style == a.style,
|
||||
_ => false,
|
||||
},
|
||||
_ => false,
|
||||
},
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Python code is content, as a formula is, whatever the cell shows.
|
||||
fn has_content(c: &Cell) -> bool {
|
||||
c.formula.is_some() || !(c.value.is_empty() || c.value.as_text() == Some(""))
|
||||
c.formula.is_some() || c.py.is_some() || !(c.value.is_empty() || c.value.as_text() == Some(""))
|
||||
}
|
||||
|
||||
/// Whether a write changed what the person entered: the formula, the Python code and its return
|
||||
/// type, or the value. A Python cell replaced by its own cached value, or by other code that
|
||||
/// shows the same `#BLOCKED!`, has changed.
|
||||
fn content_differs(b: Option<&Cell>, a: Option<&Cell>) -> bool {
|
||||
match (b, a) {
|
||||
(Some(b), Some(a)) => match (&b.formula, &a.formula) {
|
||||
(Some(x), Some(y)) => x.text != y.text,
|
||||
(None, None) => b.value != a.value,
|
||||
(None, None) => match (&b.py, &a.py) {
|
||||
(Some(x), Some(y)) => x.code != y.code || x.object != y.object,
|
||||
(None, None) => b.value != a.value,
|
||||
_ => true,
|
||||
},
|
||||
_ => true,
|
||||
},
|
||||
(Some(b), None) => has_content(b),
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! `(gap + j·(page_w + gap), gap + i·(page_h + gap))`, and its cells start at its top-left margin.
|
||||
|
||||
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef};
|
||||
use gridcraft_model::{LineIndex, Sheet, Workbook};
|
||||
use gridcraft_model::{LineIndex, PageOrder, Sheet, Workbook};
|
||||
|
||||
use crate::cmd::print::{self, PX_TO_PT, PrintOptions};
|
||||
|
||||
@@ -45,6 +45,8 @@ pub struct PageGrid {
|
||||
/// Bands holding printed data: `[first, last]` row bands and column bands.
|
||||
pub data_rows: (usize, usize),
|
||||
pub data_cols: (usize, usize),
|
||||
/// The sheet's page order, which numbers the printed pages.
|
||||
pub page_order: PageOrder,
|
||||
ri: LineIndex,
|
||||
ci: LineIndex,
|
||||
}
|
||||
@@ -96,6 +98,7 @@ impl PageGrid {
|
||||
col_bands,
|
||||
data_rows,
|
||||
data_cols,
|
||||
page_order: printed.settings.page_order,
|
||||
ri: LineIndex::new(&sh.rows, sh.default_row_height, MAX_ROWS),
|
||||
ci: LineIndex::new(&sh.cols, sh.default_col_width, MAX_COLS),
|
||||
})
|
||||
@@ -206,14 +209,14 @@ impl PageGrid {
|
||||
pub fn is_data_page(&self, i: usize, j: usize) -> bool {
|
||||
(self.data_rows.0..=self.data_rows.1).contains(&i) && (self.data_cols.0..=self.data_cols.1).contains(&j)
|
||||
}
|
||||
/// Printed page number of page `(i, j)` (down, then over) and the printed page count.
|
||||
/// Printed page number of page `(i, j)` (in the sheet's page order) and the printed page count.
|
||||
pub fn page_number(&self, i: usize, j: usize) -> Option<(usize, usize)> {
|
||||
if !self.is_data_page(i, j) {
|
||||
return None;
|
||||
}
|
||||
let rows = self.data_rows.1 - self.data_rows.0 + 1;
|
||||
let cols = self.data_cols.1 - self.data_cols.0 + 1;
|
||||
Some(((j - self.data_cols.0) * rows + (i - self.data_rows.0) + 1, rows * cols))
|
||||
Some((self.page_order.index(i - self.data_rows.0, j - self.data_cols.0, rows, cols) + 1, rows * cols))
|
||||
}
|
||||
|
||||
/// Page rows and columns overlapping the layout rectangle `[x0, x1] × [y0, y1]`.
|
||||
@@ -312,6 +315,21 @@ mod tests {
|
||||
assert!(g.is_data_page(0, 0) && !g.is_data_page(1, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_numbers_follow_the_page_order() {
|
||||
// Two pages tall and two wide, as printed; the pages beyond are "Click to add data" pages.
|
||||
let mut wb = wb_with(60, 15);
|
||||
let numbers = |wb: &Workbook| {
|
||||
let g = PageGrid::build(wb, 0, 200, 60).unwrap();
|
||||
assert_eq!((g.data_rows, g.data_cols), ((0, 1), (0, 1)));
|
||||
assert_eq!((g.page_number(2, 0), g.page_number(0, 2)), (None, None));
|
||||
[(0, 0), (1, 0), (0, 1), (1, 1)].map(|(i, j)| g.page_number(i, j).unwrap())
|
||||
};
|
||||
assert_eq!(numbers(&wb), [(1, 4), (2, 4), (3, 4), (4, 4)]);
|
||||
wb.sheet_mut(0).unwrap().print.page_order = PageOrder::OverThenDown;
|
||||
assert_eq!(numbers(&wb), [(1, 4), (3, 4), (2, 4), (4, 4)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_zones_and_text_follow_scale_with_document_and_align_with_page_margins() {
|
||||
let mut wb = wb_with(10, 3);
|
||||
|
||||
@@ -228,7 +228,7 @@ pub fn refresh(wb: &mut Workbook, sheet: usize, idx: usize, replace: bool) -> Re
|
||||
}
|
||||
if !replace {
|
||||
for (c, cell) in sh.cells.iter_range(area) {
|
||||
if (!cell.value.is_empty() || cell.formula.is_some()) && !pt.last_range.is_some_and(|r| r.contains(c)) {
|
||||
if cell.has_content() && !pt.last_range.is_some_and(|r| r.contains(c)) {
|
||||
return Err(format!(
|
||||
"There's already data in {}!{}. Do you want to replace it? (Run again with replace: true to overwrite.)",
|
||||
quote_sheet(&sh.name),
|
||||
@@ -278,7 +278,10 @@ pub fn refresh(wb: &mut Workbook, sheet: usize, idx: usize, replace: bool) -> Re
|
||||
if oc.value.is_empty() && id == StyleId::default() {
|
||||
continue;
|
||||
}
|
||||
cells.push((CellRef::new(origin.row + ri as u32, origin.col + ci as u32), Cell { value: oc.value.clone(), formula: None, style: id }));
|
||||
cells.push((
|
||||
CellRef::new(origin.row + ri as u32, origin.col + ci as u32),
|
||||
Cell { value: oc.value.clone(), formula: None, style: id, py: None },
|
||||
));
|
||||
}
|
||||
}
|
||||
// Column widths that fit the labels.
|
||||
|
||||
@@ -74,15 +74,21 @@ impl Default for Recorder {
|
||||
|
||||
/// `params` with the state a toggle set (Bold's `on`, from the command's result), as Excel
|
||||
/// records `Bold = True`: playing the step back sets that state rather than flipping it again.
|
||||
pub(crate) fn with_toggle_state(mut params: Json, result: &Json) -> Json {
|
||||
if let Some(on @ Json::Bool(_)) = result.get("on") {
|
||||
/// `python.output` without `mode` is a toggle too: it keeps the return type it set (the result's
|
||||
/// `output`) as its `mode`.
|
||||
pub(crate) fn with_toggle_state(id: &str, mut params: Json, result: &Json) -> Json {
|
||||
let state = match id {
|
||||
"python.output" => result.get("output").filter(|o| o.is_string()).map(|o| ("mode", o)),
|
||||
_ => result.get("on").filter(|o| o.is_boolean()).map(|o| ("on", o)),
|
||||
};
|
||||
if let Some((key, state)) = state {
|
||||
if params.is_null() {
|
||||
params = Json::Object(serde_json::Map::new());
|
||||
}
|
||||
if let Json::Object(p) = &mut params
|
||||
&& !p.contains_key("on")
|
||||
&& p.get(key).is_none_or(Json::is_null)
|
||||
{
|
||||
p.insert("on".into(), on.clone());
|
||||
p.insert(key.into(), state.clone());
|
||||
}
|
||||
}
|
||||
params
|
||||
|
||||
@@ -190,7 +190,7 @@ pub fn jump(sheet: &Sheet, from: CellRef, dr: i64, dc: i64) -> CellRef {
|
||||
|
||||
/// The contiguous data region around `c` (Ctrl+A / Ctrl+Shift+8, AutoSum, sort, tables).
|
||||
pub fn current_region(sheet: &Sheet, c: CellRef) -> RangeRef {
|
||||
let filled = |c: CellRef| sheet.cell(c).is_some_and(|x| !x.value.is_empty() || x.formula.is_some()) || sheet.spill.contains_key(&c);
|
||||
let filled = |c: CellRef| sheet.cell(c).is_some_and(gridcraft_model::Cell::has_content) || sheet.spill.contains_key(&c);
|
||||
// Whether a row (or column) has a filled cell between two columns (rows), inclusive.
|
||||
let row_has = |row: u32, a: u32, b: u32| (a..=b).any(|col| filled(CellRef::new(row, col)));
|
||||
let col_has = |col: u32, a: u32, b: u32| (a..=b).any(|row| filled(CellRef::new(row, col)));
|
||||
|
||||
@@ -5243,7 +5243,12 @@ fn a_script_cannot_switch_windows() {
|
||||
assert_eq!(v(&s, "A1"), Value::number(2.0));
|
||||
}
|
||||
|
||||
mod equation;
|
||||
mod macros;
|
||||
mod objects;
|
||||
mod python_cells;
|
||||
mod python_commands;
|
||||
mod python_files;
|
||||
mod seams;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
//! Equations: insert, edit, the layout cache and its rebuild on load.
|
||||
|
||||
use gridcraft_model::{Color, Shape, ShapeKind};
|
||||
use serde_json::json;
|
||||
|
||||
use super::s;
|
||||
use crate::Session;
|
||||
|
||||
fn equation(s: &Session, id: u32) -> Shape {
|
||||
s.doc().unwrap().wb.active().unwrap().shapes.iter().find(|x| x.id == id).cloned().unwrap()
|
||||
}
|
||||
|
||||
fn insert(s: &mut Session, p: serde_json::Value) -> u32 {
|
||||
s.execute("insert.equation", p).unwrap()["equation"].as_u64().unwrap() as u32
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_lays_the_equation_out_and_sizes_its_frame() {
|
||||
let mut s = s();
|
||||
let id = insert(&mut s, json!({"latex": "x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}", "cell": "C3"}));
|
||||
let e = equation(&s, id);
|
||||
assert_eq!(e.kind, ShapeKind::Equation);
|
||||
assert_eq!(e.anchor.cell.a1(), "C3");
|
||||
assert_eq!(e.line, Color::Auto, "Automatic ink");
|
||||
let list = e.math.get(&e.text).unwrap().unwrap();
|
||||
let scale = crate::cmd::equation::fit_scale(list, e.anchor.width, e.anchor.height);
|
||||
assert!((scale - 11.0 * 96.0 / 72.0).abs() < 0.01, "11 pt: {scale}");
|
||||
// A bigger size makes a bigger frame; `at` works like `cell`.
|
||||
let big = insert(&mut s, json!({"latex": "x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}", "at": "H3", "size": 22, "color": "#1F4E79"}));
|
||||
let b = equation(&s, big);
|
||||
assert!((b.anchor.width - 8.0) > 1.9 * (e.anchor.width - 8.0));
|
||||
assert_eq!(b.line, Color::Rgb(0x1F4E79));
|
||||
assert_eq!(b.anchor.cell.a1(), "H3");
|
||||
// One undo step each.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().shapes.iter().all(|x| x.id != big));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_refuses_missing_bad_or_hostile_source() {
|
||||
let mut s = s();
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"latex": ""}),
|
||||
json!({"latex": " "}),
|
||||
json!({"latex": 5}),
|
||||
json!({"latex": "x", "size": 0}),
|
||||
json!({"latex": "x", "size": f64::MAX}),
|
||||
json!({"latex": "x", "color": "nope"}),
|
||||
] {
|
||||
assert!(s.execute("insert.equation", p.clone()).is_err(), "{p}");
|
||||
}
|
||||
let e = s.execute("insert.equation", json!({"latex": "a + \\foo"})).unwrap_err().to_string();
|
||||
assert!(e.contains("character 5") && e.contains("\\foo"), "{e}");
|
||||
let e = s.execute("insert.equation", json!({"latex": "x".repeat(5000)})).unwrap_err().to_string();
|
||||
assert!(e.contains("4096"), "{e}");
|
||||
let e = s.execute("insert.equation", json!({"latex": format!("{}x{}", "{".repeat(40), "}".repeat(40))})).unwrap_err().to_string();
|
||||
assert!(e.contains("nest"), "{e}");
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().shapes.is_empty());
|
||||
// `equation.edit` is the other way a source reaches the layout engine; it has the same caps,
|
||||
// and a refused edit leaves the equation as it was.
|
||||
let id = insert(&mut s, json!({"latex": "x"}));
|
||||
let e = s.execute("equation.edit", json!({"id": id, "latex": "x".repeat(5000)})).unwrap_err().to_string();
|
||||
assert!(e.contains("4096"), "{e}");
|
||||
let e = s.execute("equation.edit", json!({"id": id, "latex": format!("{}x{}", "{".repeat(40), "}".repeat(40))})).unwrap_err().to_string();
|
||||
assert!(e.contains("nest"), "{e}");
|
||||
assert_eq!(equation(&s, id).text, "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_keeps_the_scale_and_fits_the_frame() {
|
||||
let mut s = s();
|
||||
let id = insert(&mut s, json!({"latex": "x", "size": 20}));
|
||||
let before = equation(&s, id);
|
||||
s.execute("equation.edit", json!({"id": id, "latex": "x + y + z"})).unwrap();
|
||||
let after = equation(&s, id);
|
||||
assert_eq!(after.text, "x + y + z");
|
||||
let list = after.math.get(&after.text).unwrap().unwrap();
|
||||
let scale = crate::cmd::equation::fit_scale(list, after.anchor.width, after.anchor.height);
|
||||
assert!((scale - 20.0 * 96.0 / 72.0).abs() < 0.05, "{scale}");
|
||||
assert!(after.anchor.width > before.anchor.width);
|
||||
// A frame the user resized keeps its new scale through an edit.
|
||||
s.execute("object.move", json!({"kind": "equation", "id": id, "width": after.anchor.width * 2.0, "height": after.anchor.height * 2.0})).unwrap();
|
||||
s.execute("equation.edit", json!({"id": id, "latex": "x + y"})).unwrap();
|
||||
let e = equation(&s, id);
|
||||
let scale = crate::cmd::equation::fit_scale(e.math.get(&e.text).unwrap().unwrap(), e.anchor.width, e.anchor.height);
|
||||
assert!(scale > 2.0 * 20.0 * 96.0 / 72.0 * 0.95, "{scale}");
|
||||
// Undo restores the source and its layout.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let e = equation(&s, id);
|
||||
assert_eq!(e.text, "x + y + z");
|
||||
assert!(e.math.get(&e.text).unwrap().is_ok());
|
||||
// Same source: nothing to do. Bad source: refused, nothing changes.
|
||||
let depth = s.doc().unwrap().undo.len();
|
||||
s.execute("equation.edit", json!({"id": id, "latex": "x + y + z"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().undo.len(), depth);
|
||||
assert!(s.execute("equation.edit", json!({"id": id, "latex": "\\frac{"})).is_err());
|
||||
assert!(s.execute("equation.edit", json!({"id": id, "latex": ""})).is_err());
|
||||
assert_eq!(equation(&s, id).text, "x + y + z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_source_excerpt_is_at_most_the_cap_and_whole_chars() {
|
||||
use crate::cmd::equation::source_excerpt;
|
||||
use gridcraft_math::MAX_SOURCE_BYTES as CAP;
|
||||
assert_eq!(source_excerpt(""), "");
|
||||
assert_eq!(source_excerpt("x+y"), "x+y");
|
||||
let at = "x".repeat(CAP);
|
||||
assert_eq!(source_excerpt(&at), at, "a source at the cap is whole");
|
||||
assert_eq!(source_excerpt(&format!("{at}tail")), at);
|
||||
// A three-byte character straddling the cap is left out rather than cut in two.
|
||||
let s = format!("{}\u{20AC}", "x".repeat(CAP - 1));
|
||||
assert_eq!(source_excerpt(&s), "x".repeat(CAP - 1));
|
||||
let s = format!("{}\u{20AC}\u{20AC}", "x".repeat(CAP - 2));
|
||||
assert_eq!(source_excerpt(&s).len(), CAP - 2);
|
||||
assert_eq!(source_excerpt(&"\u{1F600}".repeat(2000)).len(), CAP);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_refuses_what_is_not_an_equation() {
|
||||
let mut s = s();
|
||||
let tb = s.execute("insert.textBox", json!({"text": "hi"})).unwrap()["shape"].as_u64().unwrap();
|
||||
assert!(s.execute("equation.edit", json!({"id": tb, "latex": "x"})).is_err());
|
||||
assert!(s.execute("equation.edit", json!({"id": 999, "latex": "x"})).is_err());
|
||||
assert!(s.execute("equation.edit", json!({"latex": "x"})).is_err());
|
||||
assert!(s.execute("equation.edit", json!({"id": -1, "latex": "x"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layouts_are_rebuilt_on_load_and_not_saved() {
|
||||
let mut s = s();
|
||||
let id = insert(&mut s, json!({"latex": "e^{i\\pi} + 1 = 0"}));
|
||||
let wb = s.doc().unwrap().wb.clone();
|
||||
let bytes = serde_json::to_vec(&*wb).unwrap();
|
||||
assert!(!String::from_utf8_lossy(&bytes).contains("\"math\""), "the cache is not saved");
|
||||
let (opened, _) = crate::io::open_bytes("book.json", &bytes).unwrap();
|
||||
let raw = opened.active().unwrap().shapes.iter().find(|x| x.id == id).cloned().unwrap();
|
||||
assert!(raw.math.get(&raw.text).is_none(), "a freshly read shape has no layout");
|
||||
let mut t = Session::new();
|
||||
t.add_document(crate::DocState::new(opened, None, "Book".into(), crate::Trust::FromFile));
|
||||
let e = equation(&t, id);
|
||||
assert!(e.math.get(&e.text).unwrap().is_ok(), "opening lays it out");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_source_from_a_file_keeps_its_error() {
|
||||
let mut s = s();
|
||||
let id = insert(&mut s, json!({"latex": "x"}));
|
||||
let mut wb = (*s.doc().unwrap().wb).clone();
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
sh.shapes.iter_mut().for_each(|x| x.text = "\\frac{".into());
|
||||
}
|
||||
let mut t = Session::new();
|
||||
t.add_document(crate::DocState::new(wb, None, "Book".into(), crate::Trust::FromFile));
|
||||
let e = equation(&t, id);
|
||||
assert!(e.math.get(&e.text).unwrap().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equations_on_a_protected_sheet_are_locked() {
|
||||
let mut s = s();
|
||||
let id = insert(&mut s, json!({"latex": "x"}));
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
assert!(s.execute("equation.edit", json!({"id": id, "latex": "y"})).is_err());
|
||||
assert!(s.execute("insert.equation", json!({"latex": "y"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_with_too_many_equations_lays_out_a_bounded_amount() {
|
||||
// About 1 KB each: past the budget, equations are refused without running the layout engine,
|
||||
// so opening the file takes bounded time.
|
||||
let source = format!("x{}", "+x".repeat(500));
|
||||
let count = gridcraft_model::MAX_EQUATION_LAYOUT_BYTES / source.len() + 40;
|
||||
let mut s = s();
|
||||
let first = insert(&mut s, json!({"latex": "x", "cell": "A1"}));
|
||||
let mut wb = (*s.doc().unwrap().wb).clone();
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
let template = sh.shapes.iter().find(|x| x.id == first).cloned().unwrap();
|
||||
sh.shapes.clear();
|
||||
for i in 0..count {
|
||||
let mut e = template.clone();
|
||||
e.id = 100 + i as u32;
|
||||
e.text = source.clone();
|
||||
e.math = Default::default();
|
||||
sh.shapes.push(e);
|
||||
}
|
||||
}
|
||||
let mut t = Session::new();
|
||||
t.add_document(crate::DocState::new(wb, None, "Book".into(), crate::Trust::FromFile));
|
||||
let shapes = t.doc().unwrap().wb.active().unwrap().shapes.clone();
|
||||
let laid = shapes.iter().filter(|x| matches!(x.math.get(&x.text), Some(Ok(_)))).count();
|
||||
let refused: Vec<&Shape> =
|
||||
shapes.iter().filter(|x| matches!(x.math.get(&x.text), Some(Err(e)) if e.message == gridcraft_model::EQUATION_BUDGET_MESSAGE)).collect();
|
||||
assert_eq!(laid + refused.len(), count, "every equation has a layout or the budget's error");
|
||||
assert!(laid * source.len() <= gridcraft_model::MAX_EQUATION_LAYOUT_BYTES, "{laid} laid out");
|
||||
assert!(refused.len() >= 40, "{} refused", refused.len());
|
||||
// A later edit does not retry them all, and OK on one, unchanged, lays it out.
|
||||
let id = refused[0].id;
|
||||
t.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap();
|
||||
assert!(matches!(equation(&t, id).math.get(&source), Some(Err(_))));
|
||||
t.execute("equation.edit", json!({"id": id, "latex": source})).unwrap();
|
||||
assert!(matches!(equation(&t, id).math.get(&source), Some(Ok(_))), "editing lays it out");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_stay_unique_when_a_file_used_the_largest_id() {
|
||||
// A workbook (from a JSON file, say) whose shape has id u32::MAX: new equations and the
|
||||
// objects of a copied sheet still get ids no other object has.
|
||||
let mut s = s();
|
||||
let first = insert(&mut s, json!({"latex": "x", "cell": "A1"}));
|
||||
let mut wb = (*s.doc().unwrap().wb).clone();
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
sh.shapes.iter_mut().filter(|x| x.id == first).for_each(|x| x.id = u32::MAX);
|
||||
}
|
||||
let mut t = Session::new();
|
||||
t.add_document(crate::DocState::new(wb, None, "Book".into(), crate::Trust::FromFile));
|
||||
let a = insert(&mut t, json!({"latex": "y", "cell": "C1"}));
|
||||
let b = insert(&mut t, json!({"latex": "z", "cell": "E1"}));
|
||||
t.execute("sheet.move", json!({"sheet": 0, "to": 1, "copy": true})).unwrap();
|
||||
let wb = &t.doc().unwrap().wb;
|
||||
let mut ids: Vec<u32> = wb.sheets.iter().flat_map(|sh| sh.shapes.iter().map(|x| x.id)).collect();
|
||||
assert_eq!(ids.len(), 6, "three equations on each sheet");
|
||||
assert!(a != u32::MAX && b != u32::MAX && a != b, "{a} {b}");
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), 6, "every id is distinct: {ids:?}");
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
//! Floating objects of every kind: found, moved, listed, stacked and deleted by `{kind, id}`.
|
||||
|
||||
use gridcraft_core::CellRef;
|
||||
use gridcraft_model::{Anchor, AnchorMode, ObjKind, ObjRef, Sheet};
|
||||
use serde_json::json;
|
||||
|
||||
use super::s;
|
||||
use crate::Session;
|
||||
|
||||
fn at(a: &str) -> CellRef {
|
||||
CellRef::parse(a).unwrap()
|
||||
}
|
||||
|
||||
fn sheet(s: &Session, i: usize) -> &Sheet {
|
||||
s.doc().unwrap().wb.sheet(i).unwrap()
|
||||
}
|
||||
|
||||
fn id_of(v: &serde_json::Value, key: &str) -> u32 {
|
||||
v[key].as_u64().unwrap() as u32
|
||||
}
|
||||
|
||||
/// A table with one object of each kind on the first sheet: a chart, a picture, a shape, a
|
||||
/// slicer and an equation, in `ObjKind::ALL` order.
|
||||
fn every_kind(s: &mut Session) -> [ObjRef; 5] {
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Qty"], ["East", 3], ["West", 5], ["East", 2], ["North", 8]]})).unwrap();
|
||||
s.execute("insert.table", json!({"range": "A1:B5", "name": "Sales"})).unwrap();
|
||||
let chart = id_of(&s.execute("insert.chart", json!({"range": "B1:B5", "type": "column", "at": "D2"})).unwrap(), "chart");
|
||||
let image = super::add_image(s, "D18", AnchorMode::MoveAndSize);
|
||||
let shape = id_of(&s.execute("insert.shape", json!({"kind": "ellipse", "at": "H18"})).unwrap(), "shape");
|
||||
s.execute("selection.set", json!({"cell": "A2"})).unwrap();
|
||||
let slicer = id_of(&s.execute("insert.slicer", json!({"column": "Region", "at": "L2"})).unwrap(), "slicer");
|
||||
let equation = id_of(&s.execute("insert.equation", json!({"latex": "x^2", "cell": "L18"})).unwrap(), "equation");
|
||||
[
|
||||
ObjRef::new(ObjKind::Chart, chart),
|
||||
ObjRef::new(ObjKind::Image, image),
|
||||
ObjRef::new(ObjKind::Shape, shape),
|
||||
ObjRef::new(ObjKind::Slicer, slicer),
|
||||
ObjRef::new(ObjKind::Equation, equation),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_kind_is_found_moved_and_deleted_by_kind_and_id() {
|
||||
let mut s = s();
|
||||
let objs = every_kind(&mut s);
|
||||
for o in objs {
|
||||
assert_eq!(sheet(&s, 0).object_ref(o.id), Some(o), "the id finds its kind");
|
||||
assert_eq!(ObjKind::parse(o.kind.name()), Some(o.kind));
|
||||
s.execute("object.move", json!({"kind": o.kind.name(), "id": o.id, "at": "M20", "width": 50, "height": 30})).unwrap();
|
||||
let a = sheet(&s, 0).object_anchor(o).unwrap();
|
||||
assert_eq!((a.cell, a.width, a.height), (at("M20"), 50.0, 30.0));
|
||||
s.execute("object.setAnchorMode", json!({"kind": o.kind.name(), "id": o.id, "mode": "absolute"})).unwrap();
|
||||
assert_eq!(sheet(&s, 0).object_anchor(o).unwrap().mode, AnchorMode::Absolute);
|
||||
}
|
||||
// An equation is a shape too; a plain shape is not an equation.
|
||||
assert!(sheet(&s, 0).has_object(ObjRef::new(ObjKind::Shape, objs[4].id)));
|
||||
assert!(s.execute("object.delete", json!({"kind": "equation", "id": objs[2].id})).is_err());
|
||||
// A kind that does not match the id, an unknown id or kind, and a missing kind are refused.
|
||||
assert!(s.execute("object.delete", json!({"kind": "slicer", "id": objs[0].id})).is_err());
|
||||
assert!(s.execute("object.delete", json!({"kind": "chart", "id": 9999})).is_err());
|
||||
assert!(s.execute("object.delete", json!({"kind": "widget", "id": objs[0].id})).is_err());
|
||||
assert!(s.execute("object.delete", json!({"id": objs[0].id})).is_err(), "kind is required");
|
||||
for (n, o) in objs.into_iter().enumerate() {
|
||||
let out = s.execute("object.delete", json!({"kind": o.kind.name(), "id": o.id})).unwrap();
|
||||
assert_eq!(out["deleted"], json!({"kind": o.kind.name(), "id": o.id}));
|
||||
assert!(!sheet(&s, 0).has_object(o));
|
||||
for later in &objs[n + 1..] {
|
||||
assert!(sheet(&s, 0).has_object(*later), "deleting {o:?} leaves {later:?}");
|
||||
}
|
||||
}
|
||||
// One undo step per delete brings the equation back.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(sheet(&s, 0).has_object(objs[4]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_pane_lists_and_stacks_slicers() {
|
||||
let mut s = s();
|
||||
let objs = every_kind(&mut s);
|
||||
let second = id_of(&s.execute("insert.slicer", json!({"column": "Qty", "at": "P2"})).unwrap(), "slicer");
|
||||
let list = s.execute("arrange.selectionPane", json!({})).unwrap();
|
||||
let items = list.as_array().unwrap();
|
||||
let kinds: Vec<&str> = items.iter().map(|o| o["kind"].as_str().unwrap()).collect();
|
||||
for k in ObjKind::ALL {
|
||||
assert!(kinds.contains(&k.name()), "{k:?} listed in {kinds:?}");
|
||||
}
|
||||
assert_eq!(kinds.last(), Some(&"slicer"), "slicers stack on top");
|
||||
let names: Vec<&str> = items.iter().map(|o| o["name"].as_str().unwrap()).collect();
|
||||
assert!(names.contains(&"Region") && names.contains(&"Qty"), "{names:?}");
|
||||
assert_eq!(sheet(&s, 0).slicers.first().map(|c| c.id), Some(objs[3].id));
|
||||
s.execute("arrange.sendBackward", json!({"kind": "slicer", "id": second, "toBack": true})).unwrap();
|
||||
assert_eq!(sheet(&s, 0).slicers.first().map(|c| c.id), Some(second));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_object_params_are_refused() {
|
||||
let mut s = s();
|
||||
let objs = every_kind(&mut s);
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"kind": "chart"}),
|
||||
json!({"kind": "chart", "id": -1}),
|
||||
json!({"kind": "chart", "id": u64::MAX}),
|
||||
json!({"kind": 7, "id": objs[0].id}),
|
||||
json!({"kind": null, "id": objs[0].id}),
|
||||
] {
|
||||
for cmd in ["object.delete", "object.move", "object.setAnchorMode"] {
|
||||
assert!(s.execute(cmd, p.clone()).is_err(), "{cmd} {p}");
|
||||
}
|
||||
}
|
||||
let o = objs[3];
|
||||
s.execute("object.move", json!({"kind": o.kind.name(), "id": o.id, "dx": 1e300, "dy": -1e300, "width": 1e300, "height": -5})).unwrap();
|
||||
let a = sheet(&s, 0).object_anchor(o).unwrap();
|
||||
assert!(a.dx.is_finite() && a.dy.is_finite() && a.width <= 10000.0 && a.height >= 0.0, "{a:?}");
|
||||
}
|
||||
|
||||
fn cell_of(s: &Session, o: ObjRef) -> String {
|
||||
sheet(s, 0).object_anchor(o).unwrap().cell.a1()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_kind_follows_row_and_column_edits_and_sorts() {
|
||||
let mut s = s();
|
||||
let objs = every_kind(&mut s);
|
||||
// Anchors: chart D2, picture D18, shape H18, slicer L2, equation L18.
|
||||
s.execute("object.setAnchorMode", json!({"kind": "shape", "id": objs[2].id, "mode": "absolute"})).unwrap();
|
||||
let cells = |s: &Session| objs.map(|o| cell_of(s, o));
|
||||
assert_eq!(cells(&s), ["D2", "D18", "H18", "L2", "L18"]);
|
||||
s.execute("home.insertRows", json!({"rows": "10:11"})).unwrap();
|
||||
assert_eq!(cells(&s), ["D2", "D20", "H18", "L2", "L20"], "rows inserted above move the objects below them; an absolute shape stays");
|
||||
s.execute("home.insertColumns", json!({"cols": "C:C"})).unwrap();
|
||||
assert_eq!(cells(&s), ["E2", "E20", "H18", "M2", "M20"]);
|
||||
s.execute("home.deleteColumns", json!({"cols": "C:C"})).unwrap();
|
||||
s.execute("home.deleteRows", json!({"rows": "10:11"})).unwrap();
|
||||
assert_eq!(cells(&s), ["D2", "D18", "H18", "L2", "L18"]);
|
||||
// Sorting moves a picture anchored inside the sorted block with its row.
|
||||
s.execute("range.setValues", json!({"range": "T1", "values": [["k"], [3], [1], [2]]})).unwrap();
|
||||
let pic = ObjRef::new(ObjKind::Image, super::add_image(&mut s, "T2", AnchorMode::MoveAndSize));
|
||||
s.execute("selection.set", json!({"range": "T1:T4"})).unwrap();
|
||||
s.execute("data.sortAscending", json!({"header": true, "column": "T"})).unwrap();
|
||||
assert_eq!(cell_of(&s, pic), "T4", "the row holding 3 sorts last");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(cell_of(&s, pic), "T2");
|
||||
}
|
||||
|
||||
/// A picture at `cell` with anchor `mode`, offset `(dx, dy)` and size `(width, height)`, added
|
||||
/// straight to the model like `add_image`, so a sort that follows is the only step to undo.
|
||||
fn picture(s: &mut Session, cell: &str, mode: AnchorMode, dx: f32, dy: f32, width: f32, height: f32) -> ObjRef {
|
||||
let id = super::add_image(s, cell, mode);
|
||||
let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb);
|
||||
if let Some(img) = wb.sheet_mut(0).unwrap().images.iter_mut().find(|i| i.id == id) {
|
||||
img.anchor = Anchor { dx, dy, width, height, ..img.anchor };
|
||||
}
|
||||
ObjRef::new(ObjKind::Image, id)
|
||||
}
|
||||
|
||||
/// The anchor of `o` as (cell, dx, dy, width, height).
|
||||
fn anchor_of(s: &Session, o: ObjRef) -> (String, f32, f32, f32, f32) {
|
||||
let a = sheet(s, 0).object_anchor(o).unwrap();
|
||||
(a.cell.a1(), a.dx, a.dy, a.width, a.height)
|
||||
}
|
||||
|
||||
/// Sorting rows under a picture follows its anchor mode, as Excel's "Move and size with cells",
|
||||
/// "Move but don't size with cells" and "Don't move or size with cells" do.
|
||||
#[test]
|
||||
fn sorting_rows_under_a_picture_follows_its_anchor_mode() {
|
||||
let mut s = s();
|
||||
// Rows 2..5 hold 3, 1, 4, 2; sorted ascending they read 1, 2, 3, 4, so row 2 moves to row 4,
|
||||
// row 3 to row 2, row 4 to row 5 and row 5 to row 3. Default rows are 20 pt tall.
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["k"], [3], [1], [4], [2]]})).unwrap();
|
||||
// Three pictures inside one row each (3 pt down, 12 pt tall), one per anchor mode.
|
||||
let sized = picture(&mut s, "A2", AnchorMode::MoveAndSize, 5.0, 3.0, 30.0, 12.0);
|
||||
let moved = picture(&mut s, "A3", AnchorMode::MoveOnly, 5.0, 3.0, 30.0, 12.0);
|
||||
let pinned = picture(&mut s, "A4", AnchorMode::Absolute, 5.0, 3.0, 30.0, 12.0);
|
||||
// A "move and size" picture spanning rows 2 and 3: its top is 10 pt into row 2 (y 30) and
|
||||
// its bottom 15 pt into row 3 (y 55). The sort puts row 3 above row 2.
|
||||
let tall = picture(&mut s, "A2", AnchorMode::MoveAndSize, 5.0, 10.0, 30.0, 25.0);
|
||||
let before: Vec<_> = [sized, moved, pinned, tall].iter().map(|&o| anchor_of(&s, o)).collect();
|
||||
s.execute("selection.set", json!({"range": "A1:A5"})).unwrap();
|
||||
s.execute("data.sortAscending", json!({"header": true, "column": "A"})).unwrap();
|
||||
assert_eq!(
|
||||
anchor_of(&s, sized),
|
||||
("A4".into(), 5.0, 3.0, 30.0, 12.0),
|
||||
"move and size: travels with row 2 to row 4, re-fitted to the same 20 pt row"
|
||||
);
|
||||
assert_eq!(anchor_of(&s, moved), ("A2".into(), 5.0, 3.0, 30.0, 12.0), "move only: travels with row 3 to row 2 at its own size");
|
||||
assert_eq!(anchor_of(&s, pinned), ("A4".into(), 5.0, 3.0, 30.0, 12.0), "absolute: stays while the cells move under it");
|
||||
// The tall picture's bottom row now sorts above its top row, so there is nothing to stretch
|
||||
// it between: it travels with its top-left row and keeps its size instead of collapsing.
|
||||
assert_eq!(anchor_of(&s, tall), ("A4".into(), 5.0, 10.0, 30.0, 25.0), "two rows swapped: moves with its top row at its old size");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let after: Vec<_> = [sized, moved, pinned, tall].iter().map(|&o| anchor_of(&s, o)).collect();
|
||||
assert_eq!(after, before, "undo restores every anchor");
|
||||
}
|
||||
|
||||
/// A sort by columns follows the anchor mode the same way.
|
||||
#[test]
|
||||
fn sorting_columns_under_a_picture_follows_its_anchor_mode() {
|
||||
let mut s = s();
|
||||
// Row 10 holds a header in A and 3, 1, 2 in B..D; sorted ascending, column B moves to D,
|
||||
// C to B and D to C. Default columns are 64 pt wide.
|
||||
s.execute("range.setValues", json!({"range": "A10", "values": [["k", 3, 1, 2]]})).unwrap();
|
||||
let sized = picture(&mut s, "B10", AnchorMode::MoveAndSize, 5.0, 3.0, 30.0, 12.0);
|
||||
let moved = picture(&mut s, "C10", AnchorMode::MoveOnly, 5.0, 3.0, 30.0, 12.0);
|
||||
let pinned = picture(&mut s, "D10", AnchorMode::Absolute, 5.0, 3.0, 30.0, 12.0);
|
||||
// A "move and size" picture spanning columns B and C: from x 104 (40 pt into B) to x 154
|
||||
// (26 pt into C). The sort puts column C before column B.
|
||||
let wide = picture(&mut s, "B10", AnchorMode::MoveAndSize, 40.0, 3.0, 50.0, 12.0);
|
||||
let before: Vec<_> = [sized, moved, pinned, wide].iter().map(|&o| anchor_of(&s, o)).collect();
|
||||
s.execute("data.sort", json!({"range": "A10:D10", "header": true, "orientation": "columns", "keys": [{"row": 10}]})).unwrap();
|
||||
assert_eq!(anchor_of(&s, sized), ("D10".into(), 5.0, 3.0, 30.0, 12.0), "move and size: travels with column B to D at the same width");
|
||||
assert_eq!(anchor_of(&s, moved), ("B10".into(), 5.0, 3.0, 30.0, 12.0), "move only: travels with column C to B at its own size");
|
||||
assert_eq!(anchor_of(&s, pinned), ("D10".into(), 5.0, 3.0, 30.0, 12.0), "absolute: stays");
|
||||
assert_eq!(anchor_of(&s, wide), ("D10".into(), 40.0, 3.0, 50.0, 12.0), "two columns swapped: moves with its left column at its old size");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let after: Vec<_> = [sized, moved, pinned, wide].iter().map(|&o| anchor_of(&s, o)).collect();
|
||||
assert_eq!(after, before, "undo restores every anchor");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slicers_follow_inserted_rows_like_other_objects() {
|
||||
let mut s = s();
|
||||
let objs = every_kind(&mut s);
|
||||
s.execute("home.insertRows", json!({"rows": "1:2"})).unwrap();
|
||||
assert_eq!(cell_of(&s, objs[3]), "L4");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(cell_of(&s, objs[3]), "L2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chart_series_follow_cut_moves_renames_and_sheet_deletes() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[3], [5], [2], [8]]})).unwrap();
|
||||
let chart = id_of(&s.execute("insert.chart", json!({"range": "A1:A4", "type": "column"})).unwrap(), "chart");
|
||||
let values = |s: &Session| sheet(s, 0).charts.iter().find(|c| c.id == chart).map(|c| c.series[0].values.clone()).unwrap_or_default();
|
||||
s.execute("edit.cut", json!({"range": "A1:A4"})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "F2"})).unwrap();
|
||||
assert!(values(&s).contains("$F$2:$F$5"), "a cut-move takes the series with the cells: {}", values(&s));
|
||||
s.execute("home.insertSheet", json!({"name": "Other"})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3]]})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().charts[0].series[0].values = "Other!$A$1:$A$3".into();
|
||||
s.execute("sheet.rename", json!({"sheet": 1, "name": "Inputs"})).unwrap();
|
||||
assert_eq!(values(&s), "Inputs!$A$1:$A$3");
|
||||
s.execute("home.deleteSheet", json!({"sheet": 1})).unwrap();
|
||||
assert!(values(&s).contains("#REF!"), "a series into a deleted sheet becomes #REF!: {}", values(&s));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(values(&s), "Inputs!$A$1:$A$3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copied_sheet_gives_its_objects_fresh_ids() {
|
||||
let mut s = s();
|
||||
let objs = every_kind(&mut s);
|
||||
s.execute("sheet.move", json!({"sheet": 0, "to": 1, "copy": true})).unwrap();
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
let ids_of = |sh: &Sheet| -> Vec<u32> {
|
||||
sh.charts
|
||||
.iter()
|
||||
.map(|c| c.id)
|
||||
.chain(sh.images.iter().map(|c| c.id))
|
||||
.chain(sh.shapes.iter().map(|c| c.id))
|
||||
.chain(sh.slicers.iter().map(|c| c.id))
|
||||
.collect()
|
||||
};
|
||||
let mut ids: Vec<u32> = wb.sheets.iter().flat_map(|sh| ids_of(sh)).collect();
|
||||
assert_eq!(ids.len(), 2 * objs.len());
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), 2 * objs.len(), "every object id stays unique across the workbook");
|
||||
let copy = sheet(&s, 1);
|
||||
let mut kinds: Vec<&str> = ids_of(copy).into_iter().filter_map(|id| copy.object_ref(id)).map(|o| o.kind.name()).collect();
|
||||
let mut want: Vec<&str> = objs.iter().map(|o| o.kind.name()).collect();
|
||||
kinds.sort_unstable();
|
||||
want.sort_unstable();
|
||||
assert_eq!(kinds, want, "the copies keep their kinds");
|
||||
assert!(s.doc().unwrap().wb.next_object_id() > *ids.last().unwrap_or(&0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bubble_sizes_follow_edits_like_the_other_series_references() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 3, 10], [2, 5, 20], [3, 2, 30]]})).unwrap();
|
||||
let chart = id_of(&s.execute("insert.chart", json!({"range": "A1:B3", "type": "bubble"})).unwrap(), "chart");
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().charts.iter_mut().find(|c| c.id == chart).unwrap().series[0]
|
||||
.bubble_sizes = Some("Sheet1!$C$1:$C$3".into());
|
||||
let sizes = |s: &Session| sheet(s, 0).charts.iter().find(|c| c.id == chart).and_then(|c| c.series[0].bubble_sizes.clone()).unwrap_or_default();
|
||||
s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap();
|
||||
assert_eq!(sizes(&s), "Sheet1!$C$2:$C$4", "an inserted row moves the bubble sizes with their cells");
|
||||
s.execute("edit.cut", json!({"range": "C2:C4"})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "F2"})).unwrap();
|
||||
assert_eq!(sizes(&s), "Sheet1!$F$2:$F$4", "a cut-move takes the bubble sizes with the cells");
|
||||
s.execute("sheet.rename", json!({"sheet": 0, "name": "Data"})).unwrap();
|
||||
assert_eq!(sizes(&s), "Data!$F$2:$F$4", "a renamed sheet is renamed in the bubble sizes");
|
||||
}
|
||||
|
||||
/// A file can carry an object with the largest id. New objects and a copied sheet's objects then
|
||||
/// take free ids instead of the largest one again, so `{kind, id}` still names one object.
|
||||
#[test]
|
||||
fn object_ids_stay_unique_when_a_file_used_the_largest_id() {
|
||||
let mut s = s();
|
||||
let first = id_of(&s.execute("insert.shape", json!({"kind": "rectangle", "at": "B2"})).unwrap(), "shape");
|
||||
// The file's shape carries the largest id (set on the model, as a loaded file would).
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().shapes.iter_mut().find(|o| o.id == first).unwrap().id = u32::MAX;
|
||||
let second = id_of(&s.execute("insert.shape", json!({"kind": "ellipse", "at": "D2"})).unwrap(), "shape");
|
||||
let third = id_of(&s.execute("insert.shape", json!({"kind": "triangle", "at": "F2"})).unwrap(), "shape");
|
||||
assert!(second != u32::MAX && third != u32::MAX && second != third, "new shapes take free ids, not the largest again: {second}, {third}");
|
||||
s.execute("sheet.move", json!({"sheet": 0, "to": 1, "copy": true})).unwrap();
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
let mut ids: Vec<u32> = wb.sheets.iter().flat_map(|sh| sh.shapes.iter().map(|o| o.id)).collect();
|
||||
assert_eq!(ids.len(), 6);
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
assert_eq!(ids.len(), 6, "every shape id is distinct across both sheets");
|
||||
}
|
||||
|
||||
/// A file's anchors are not held to the 10,000 points `object.move` gives a new size. A picture
|
||||
/// a file made 12,000 wide keeps that width when it is moved, shrinks by what a resize asks
|
||||
/// (11,990 stays 11,990, no jump to 10,000) and never grows further; a dimension the file kept
|
||||
/// within the limit is still capped there.
|
||||
#[test]
|
||||
fn an_oversized_imported_object_keeps_its_size_when_moved_and_shrinks_only_by_a_resize() {
|
||||
let mut s = s();
|
||||
let image = super::add_image(&mut s, "B2", AnchorMode::MoveOnly);
|
||||
// The file's picture is 12,000 by 300: set on the model, then written and read back as a file.
|
||||
{
|
||||
let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb);
|
||||
let a = wb.sheet_mut(0).unwrap().object_anchor_mut(ObjRef::new(ObjKind::Image, image)).unwrap();
|
||||
a.width = 12_000.0;
|
||||
a.height = 300.0;
|
||||
}
|
||||
let saved = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
|
||||
s.execute("file.open", json!({"name": "wide.xlsx", "base64": saved["base64"]})).unwrap();
|
||||
let o = ObjRef::new(ObjKind::Image, sheet(&s, 0).images[0].id);
|
||||
let size = |s: &Session| {
|
||||
let a = sheet(s, 0).object_anchor(o).unwrap();
|
||||
(a.width.round(), a.height.round())
|
||||
};
|
||||
assert_eq!(size(&s), (12_000.0, 300.0), "the reader keeps the file's size");
|
||||
s.execute("object.move", json!({"kind": "image", "id": o.id, "at": "D5", "dx": 3, "dy": 4})).unwrap();
|
||||
let a = sheet(&s, 0).object_anchor(o).unwrap();
|
||||
assert_eq!((a.cell, a.dx, a.dy), (at("D5"), 3.0, 4.0));
|
||||
assert_eq!(size(&s), (12_000.0, 300.0), "a move without a size keeps the file's size");
|
||||
s.execute("object.move", json!({"kind": "image", "id": o.id, "width": 12_010})).unwrap();
|
||||
assert_eq!(size(&s), (12_000.0, 300.0), "the object does not grow past what the file gave");
|
||||
s.execute("object.move", json!({"kind": "image", "id": o.id, "width": 11_990})).unwrap();
|
||||
assert_eq!(size(&s), (11_990.0, 300.0), "a resize shrinks by what it asks, with no jump to 10,000");
|
||||
s.execute("object.move", json!({"kind": "image", "id": o.id, "width": 50_000, "height": 50_000})).unwrap();
|
||||
assert_eq!(size(&s), (11_990.0, 10_000.0), "hostile sizes are capped: at the current width, and at 10,000 for a height the file kept within it");
|
||||
}
|
||||
|
||||
/// `chart.set` follows the same rule: the Format Chart pane's Size fields send the unchanged
|
||||
/// dimension back with the edited one, so a chart a file made 12,000 wide keeps that width when
|
||||
/// its height is edited, shrinks by what is asked and never grows further.
|
||||
#[test]
|
||||
fn chart_set_keeps_a_chart_size_a_file_made_larger_than_the_limit() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3]]})).unwrap();
|
||||
let chart = id_of(&s.execute("insert.chart", json!({"range": "A1:A3", "type": "column", "at": "C2"})).unwrap(), "chart");
|
||||
// The file's chart is 12,000 wide (set on the model, as a loaded file would).
|
||||
let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb);
|
||||
wb.sheet_mut(0).unwrap().charts.iter_mut().find(|c| c.id == chart).unwrap().anchor.width = 12_000.0;
|
||||
let size = |s: &Session| {
|
||||
let c = sheet(s, 0).charts.iter().find(|c| c.id == chart).unwrap();
|
||||
(c.anchor.width, c.anchor.height)
|
||||
};
|
||||
s.execute("chart.set", json!({"chart": chart, "width": 12_000, "height": 250})).unwrap();
|
||||
assert_eq!(size(&s), (12_000.0, 250.0), "the unchanged width sent back with a new height stays");
|
||||
s.execute("chart.set", json!({"chart": chart, "width": 11_990})).unwrap();
|
||||
assert_eq!(size(&s), (11_990.0, 250.0), "a resize shrinks by what it asks");
|
||||
s.execute("chart.set", json!({"chart": chart, "width": 50_000, "height": 50_000})).unwrap();
|
||||
assert_eq!(size(&s), (11_990.0, 10_000.0), "hostile sizes are capped at the current width and at 10,000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrange_and_align_match_a_shape_id_to_its_kind() {
|
||||
// `arrange.*` and `arrange.align` under `kind: "equation"` act on equations only: an
|
||||
// ordinary shape's id given as an equation is refused by `arrange` and left in place by
|
||||
// `align`, as `object.delete` refuses it. An equation is a shape too, so its id works under
|
||||
// `shape`, as for the other object commands; an unknown kind, and an id of no object of the
|
||||
// kind, are refused instead of acting on whatever shape has the id.
|
||||
let mut s = s();
|
||||
let objs = every_kind(&mut s);
|
||||
let (shape, equation) = (objs[2], objs[4]);
|
||||
let order = |s: &Session| -> Vec<u32> { sheet(s, 0).shapes.iter().map(|c| c.id).collect() };
|
||||
assert_eq!(order(&s), vec![shape.id, equation.id]);
|
||||
assert!(s.execute("arrange.bringForward", json!({"kind": "equation", "id": shape.id, "toFront": true})).is_err());
|
||||
assert_eq!(order(&s), vec![shape.id, equation.id], "the shape is not reordered as an equation");
|
||||
s.execute("arrange.sendBackward", json!({"kind": "equation", "id": equation.id, "toBack": true})).unwrap();
|
||||
assert_eq!(order(&s), vec![equation.id, shape.id]);
|
||||
s.execute("arrange.bringForward", json!({"kind": "shape", "id": equation.id, "toFront": true})).unwrap();
|
||||
assert_eq!(order(&s), vec![shape.id, equation.id], "an equation is a shape too");
|
||||
assert!(s.execute("arrange.bringForward", json!({"kind": "widget", "id": shape.id})).is_err());
|
||||
assert!(s.execute("arrange.bringForward", json!({"kind": "chart", "id": 9999})).is_err());
|
||||
assert!(s.execute("arrange.bringForward", json!({"kind": "shape", "id": objs[0].id})).is_err(), "a chart's id is not a shape");
|
||||
// Align: the shape's id given as an equation is refused and nothing moves; the two equations
|
||||
// line up on their own.
|
||||
let second = ObjRef::new(ObjKind::Equation, id_of(&s.execute("insert.equation", json!({"latex": "y", "cell": "B30"})).unwrap(), "equation"));
|
||||
let before = (anchor_of(&s, shape), anchor_of(&s, equation));
|
||||
assert!(s.execute("arrange.align", json!({"kind": "equation", "ids": [shape.id, equation.id, second.id], "align": "left"})).is_err());
|
||||
assert_eq!((anchor_of(&s, shape), anchor_of(&s, equation)), before, "nothing is aligned with a refused id");
|
||||
s.execute("arrange.align", json!({"kind": "equation", "ids": [equation.id, second.id], "align": "left"})).unwrap();
|
||||
let (a, b) = (sheet(&s, 0).object_anchor(equation).unwrap(), sheet(&s, 0).object_anchor(second).unwrap());
|
||||
assert_eq!((a.cell.col, a.dx), (b.cell.col, b.dx), "the equations line up");
|
||||
assert!(s.execute("arrange.align", json!({"kind": "widget", "ids": [shape.id, equation.id], "align": "left"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrange_refuses_a_kind_that_is_present_but_not_a_name() {
|
||||
// `kind` is `shape` only when the parameter is absent: `null`, a number or another non-name
|
||||
// is refused, so a shape's id is not reordered or aligned under it. The `object.*` commands
|
||||
// refuse the same values, their kind being required.
|
||||
let mut s = s();
|
||||
let objs = every_kind(&mut s);
|
||||
let (shape, equation) = (objs[2], objs[4]);
|
||||
let order = |s: &Session| -> Vec<u32> { sheet(s, 0).shapes.iter().map(|c| c.id).collect() };
|
||||
let before = (order(&s), anchor_of(&s, shape));
|
||||
for kind in [json!(null), json!(7), json!(["shape"]), json!({"kind": "shape"}), json!(true)] {
|
||||
assert!(s.execute("arrange.bringForward", json!({"kind": kind, "id": shape.id, "toFront": true})).is_err(), "{kind}");
|
||||
assert!(s.execute("arrange.align", json!({"kind": kind, "ids": [shape.id, equation.id], "align": "left"})).is_err(), "{kind}");
|
||||
assert!(s.execute("object.move", json!({"kind": kind, "id": shape.id, "at": "A1"})).is_err(), "{kind}");
|
||||
}
|
||||
assert_eq!((order(&s), anchor_of(&s, shape)), before, "nothing moved");
|
||||
// Absent, the kind is `shape`, as before.
|
||||
s.execute("arrange.bringForward", json!({"id": shape.id, "toFront": true})).unwrap();
|
||||
assert_eq!(order(&s).last(), Some(&shape.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_refuses_an_id_that_is_not_an_object_of_the_kind() {
|
||||
// `arrange.align` and `arrange.group` refuse an id in `ids` that is not an object of the
|
||||
// kind, or not a number, as `arrange` and the `object.*` commands refuse it, instead of
|
||||
// aligning the rest and reporting success; nothing moves. With fewer than two ids they
|
||||
// change nothing and succeed.
|
||||
let mut s = s();
|
||||
let objs = every_kind(&mut s);
|
||||
let (shape, equation) = (objs[2], objs[4]);
|
||||
let second = ObjRef::new(ObjKind::Equation, id_of(&s.execute("insert.equation", json!({"latex": "y", "cell": "B30"})).unwrap(), "equation"));
|
||||
let before = (anchor_of(&s, shape), anchor_of(&s, equation), anchor_of(&s, second));
|
||||
for ids in [
|
||||
json!([shape.id, equation.id, second.id]),
|
||||
json!([equation.id, second.id, 9999]),
|
||||
json!([equation.id, second.id, -1]),
|
||||
json!([equation.id, second.id, "x"]),
|
||||
json!([equation.id, second.id, 1e12]),
|
||||
json!([equation.id, second.id, null]),
|
||||
] {
|
||||
assert!(s.execute("arrange.align", json!({"kind": "equation", "ids": ids, "align": "left"})).is_err(), "{ids}");
|
||||
assert!(s.execute("arrange.group", json!({"kind": "equation", "ids": ids})).is_err(), "{ids}");
|
||||
}
|
||||
assert_eq!((anchor_of(&s, shape), anchor_of(&s, equation), anchor_of(&s, second)), before, "nothing moved");
|
||||
s.execute("arrange.align", json!({"kind": "equation", "ids": [equation.id], "align": "left"})).unwrap();
|
||||
s.execute("arrange.align", json!({"kind": "equation", "align": "left"})).unwrap();
|
||||
assert_eq!(anchor_of(&s, equation), before.1, "fewer than two ids change nothing");
|
||||
s.execute("arrange.align", json!({"kind": "equation", "ids": [equation.id, second.id], "align": "left"})).unwrap();
|
||||
let (a, b) = (sheet(&s, 0).object_anchor(equation).unwrap(), sheet(&s, 0).object_anchor(second).unwrap());
|
||||
assert_eq!((a.cell.col, a.dx), (b.cell.col, b.dx), "the equations line up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_refuses_ids_that_are_not_an_array() {
|
||||
// `ids` may be absent (nothing to align) but, present, must be an array of object ids: `null`,
|
||||
// a number, a string or an object is refused by `arrange.align` and `arrange.group`, and
|
||||
// nothing moves.
|
||||
let mut s = s();
|
||||
let objs = every_kind(&mut s);
|
||||
let (shape, equation) = (objs[2], objs[4]);
|
||||
let before = (anchor_of(&s, shape), anchor_of(&s, equation));
|
||||
for ids in [json!(null), json!(7), json!("x"), json!({"ids": [1]})] {
|
||||
assert!(s.execute("arrange.align", json!({"kind": "shape", "ids": ids, "align": "left"})).is_err(), "{ids}");
|
||||
assert!(s.execute("arrange.group", json!({"kind": "shape", "ids": ids})).is_err(), "{ids}");
|
||||
}
|
||||
s.execute("arrange.align", json!({"kind": "shape", "align": "left"})).unwrap();
|
||||
s.execute("arrange.group", json!({"kind": "shape"})).unwrap();
|
||||
assert_eq!((anchor_of(&s, shape), anchor_of(&s, equation)), before, "nothing moved");
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
//! Python cells under edits: what keeps, moves, rewrites or removes their code.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{CellRef, Value};
|
||||
use gridcraft_model::{Cell, PyCode};
|
||||
use serde_json::json;
|
||||
|
||||
use super::s;
|
||||
use crate::Session;
|
||||
|
||||
/// Makes `cell` on sheet `sheet` a Python cell with `code` and a cached value.
|
||||
fn plant(s: &mut Session, sheet: usize, cell: &str, code: &str) {
|
||||
let d = s.doc_mut().unwrap();
|
||||
let mut wb = (*d.wb).clone();
|
||||
let sh = wb.sheet_mut(sheet).unwrap();
|
||||
let at = CellRef::parse(cell).unwrap();
|
||||
let style = sh.style_id(at);
|
||||
sh.set_cell(at, Cell { value: Value::Number(1.0), formula: None, style, py: Some(Arc::new(PyCode::new(code))) });
|
||||
d.wb = Arc::new(wb);
|
||||
}
|
||||
|
||||
fn code(s: &Session, sheet: usize, cell: &str) -> Option<String> {
|
||||
let d = s.doc().unwrap();
|
||||
d.wb.sheet(sheet).unwrap().cell(CellRef::parse(cell).unwrap()).and_then(|c| c.py.as_ref()).map(|p| p.code.clone())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_py_formulas_points_to_python_set() {
|
||||
let mut s = s();
|
||||
let e = s.execute("cell.set", json!({"cell": "A1", "input": "=PY(\"1+1\",0)"})).unwrap_err();
|
||||
assert!(e.to_string().contains("python.set"), "{e}");
|
||||
assert!(s.execute("cell.set", json!({"cell": "A1", "input": "=py"})).is_err());
|
||||
// Other functions starting with PY are not affected.
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "=PI()"})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entering_or_clearing_a_python_cell_removes_its_code() {
|
||||
let mut s = s();
|
||||
plant(&mut s, 0, "A1", "1 + 1");
|
||||
plant(&mut s, 0, "A2", "2 + 2");
|
||||
plant(&mut s, 0, "A3", "3 + 3");
|
||||
let got = s.execute("cell.get", json!({"cell": "A1"})).unwrap();
|
||||
assert_eq!(got["input"], "1 + 1");
|
||||
assert_eq!(got["python"]["code"], "1 + 1");
|
||||
assert_eq!(got["python"]["output"], "value");
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "hello"})).unwrap();
|
||||
assert_eq!(code(&s, 0, "A1"), None);
|
||||
s.execute("edit.clearFormats", json!({"range": "A2"})).unwrap();
|
||||
assert_eq!(code(&s, 0, "A2").as_deref(), Some("2 + 2"), "clearing formats keeps the code");
|
||||
s.execute("edit.clearContents", json!({"range": "A2"})).unwrap();
|
||||
assert_eq!(code(&s, 0, "A2"), None);
|
||||
s.execute("edit.clearAll", json!({"range": "A3"})).unwrap();
|
||||
assert_eq!(code(&s, 0, "A3"), None);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(code(&s, 0, "A3").as_deref(), Some("3 + 3"), "undo brings the code back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_fill_and_sort_carry_the_code_and_paste_values_drops_it() {
|
||||
let mut s = s();
|
||||
plant(&mut s, 0, "A1", "xl('B1') * 2");
|
||||
s.execute("edit.copy", json!({"range": "A1"})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "C1"})).unwrap();
|
||||
assert_eq!(code(&s, 0, "C1").as_deref(), Some("xl('B1') * 2"), "the code is copied as it is");
|
||||
s.execute("edit.pasteSpecial", json!({"at": "D1", "what": "values"})).unwrap();
|
||||
assert_eq!(code(&s, 0, "D1"), None);
|
||||
assert_eq!(super::v(&s, "D1"), Value::Number(1.0), "the cached value is pasted");
|
||||
s.execute("edit.fillDown", json!({"range": "A1:A3"})).unwrap();
|
||||
assert_eq!(code(&s, 0, "A3").as_deref(), Some("xl('B1') * 2"));
|
||||
// Sorting moves Python cells with their rows.
|
||||
s.execute("range.setValues", json!({"range": "E1", "values": [[3], [1], [2]]})).unwrap();
|
||||
plant(&mut s, 0, "F1", "'three'");
|
||||
plant(&mut s, 0, "F2", "'one'");
|
||||
s.execute("data.sort", json!({"range": "E1:F3", "keys": [{"column": "E"}]})).unwrap();
|
||||
assert_eq!(code(&s, 0, "F1").as_deref(), Some("'one'"));
|
||||
assert_eq!(code(&s, 0, "F3").as_deref(), Some("'three'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xl_references_follow_inserts_moves_and_renames() {
|
||||
let mut s = s();
|
||||
s.execute("home.insertSheet", json!({})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
let other = s.doc().unwrap().wb.sheet(1).unwrap().name.clone();
|
||||
plant(&mut s, 0, "C5", &format!("a = xl(\"A1:A3\")\nb = xl('{other}!B2', headers=True)\nc = xl(dynamic)\n# xl(\"A1\") stays in comments"));
|
||||
s.execute("home.insertRows", json!({"rows": "2:2"})).unwrap();
|
||||
let c = code(&s, 0, "C6").unwrap();
|
||||
assert!(c.starts_with("a = xl(\"A1:A4\")\n"), "{c}");
|
||||
assert!(c.contains(&format!("xl('{other}!B2', headers=True)")), "{c}");
|
||||
assert!(c.contains("# xl(\"A1\") stays"), "{c}");
|
||||
s.execute("sheet.rename", json!({"sheet": 1, "name": "Data"})).unwrap();
|
||||
assert!(code(&s, 0, "C6").unwrap().contains("xl('Data!B2', headers=True)"));
|
||||
// Cutting the cells a reference names moves the reference with them.
|
||||
s.execute("edit.cut", json!({"range": "A1:A4"})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "H1"})).unwrap();
|
||||
assert!(code(&s, 0, "C6").unwrap().starts_with("a = xl(\"H1:H4\")"), "{:?}", code(&s, 0, "C6"));
|
||||
// Deleting the referenced cells leaves #REF!, which fails when the code runs.
|
||||
// The selection is elsewhere, so only the named column goes.
|
||||
s.execute("selection.set", json!({"range": "A1"})).unwrap();
|
||||
s.execute("home.deleteColumns", json!({"cols": "H:H"})).unwrap();
|
||||
assert!(code(&s, 0, "C6").unwrap().starts_with("a = xl(\"#REF!\")"), "{:?}", code(&s, 0, "C6"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rename_to_a_name_that_needs_quotes_rewrites_single_quoted_references() {
|
||||
let mut s = s();
|
||||
s.execute("home.insertSheet", json!({})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
let other = s.doc().unwrap().wb.sheet(1).unwrap().name.clone();
|
||||
plant(&mut s, 0, "A1", &format!("a = xl('{other}!B2')\nb = xl(\"{other}!C3\")"));
|
||||
s.execute("sheet.rename", json!({"sheet": 1, "name": "Q4 2026"})).unwrap();
|
||||
assert_eq!(code(&s, 0, "A1").unwrap(), "a = xl(\"'Q4 2026'!B2\")\nb = xl(\"'Q4 2026'!C3\")");
|
||||
// A name with both quotes in it: the literal's own quote is escaped.
|
||||
s.execute("sheet.rename", json!({"sheet": 1, "name": "Bob's \"Q4\""})).unwrap();
|
||||
let c = code(&s, 0, "A1").unwrap();
|
||||
assert_eq!(c, "a = xl(\"'Bob''s \\\"Q4\\\"'!B2\")\nb = xl(\"'Bob''s \\\"Q4\\\"'!C3\")");
|
||||
let refs: Vec<Option<String>> = gridcraft_model::python::xl_calls(&c).into_iter().map(|x| x.reference).collect();
|
||||
assert_eq!(refs, [Some("'Bob''s \"Q4\"'!B2".to_string()), Some("'Bob''s \"Q4\"'!C3".to_string())]);
|
||||
// And back to a plain name.
|
||||
s.execute("sheet.rename", json!({"sheet": 1, "name": "Data"})).unwrap();
|
||||
assert_eq!(code(&s, 0, "A1").unwrap(), "a = xl(\"Data!B2\")\nb = xl(\"Data!C3\")");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cut_to_another_sheet_keeps_references_on_the_source_sheet() {
|
||||
let mut s = s();
|
||||
s.execute("home.insertSheet", json!({})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
let (src, dest) = {
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
(wb.sheet(0).unwrap().name.clone(), wb.sheet(1).unwrap().name.clone())
|
||||
};
|
||||
plant(&mut s, 0, "A1", "a = xl(\"B5\")\nb = xl('A2')");
|
||||
plant(&mut s, 0, "A2", "1");
|
||||
s.execute("edit.cut", json!({"range": "A1:A2"})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 1})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "C1"})).unwrap();
|
||||
// B5 stays on the source sheet; A2 moved with the cut, so it follows to the destination.
|
||||
assert_eq!(code(&s, 1, "C1").unwrap(), format!("a = xl(\"{src}!B5\")\nb = xl('{dest}!C2')"));
|
||||
assert_eq!(code(&s, 0, "A1"), None);
|
||||
// Within one sheet nothing is qualified.
|
||||
s.execute("edit.cut", json!({"range": "C1"})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "E1"})).unwrap();
|
||||
assert_eq!(code(&s, 1, "E1").unwrap(), format!("a = xl(\"{src}!B5\")\nb = xl('{dest}!C2')"));
|
||||
// A copy to another sheet still carries the code as it is.
|
||||
plant(&mut s, 0, "A9", "xl(\"B5\")");
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
s.execute("edit.copy", json!({"range": "A9"})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 1})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "A9"})).unwrap();
|
||||
assert_eq!(code(&s, 1, "A9").as_deref(), Some("xl(\"B5\")"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goal_seek_and_solver_refuse_to_change_a_python_cell() {
|
||||
let mut s = s();
|
||||
plant(&mut s, 0, "A1", "2");
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "=A1*2"})).unwrap();
|
||||
let e = s.execute("data.goalSeek", json!({"set": "B1", "to": 10, "changing": "A1"})).unwrap_err();
|
||||
assert!(e.to_string().contains("must contain a value"), "{e}");
|
||||
assert_eq!(code(&s, 0, "A1").as_deref(), Some("2"), "the code is kept");
|
||||
// The set cell must be a formula: a Python cell isn't one this build can recalculate.
|
||||
s.execute("cell.set", json!({"cell": "C1", "input": "3"})).unwrap();
|
||||
assert!(s.execute("data.goalSeek", json!({"set": "A1", "to": 10, "changing": "C1"})).is_err());
|
||||
let e = s.execute("data.solver", json!({"objective": "B1", "goal": "max", "variables": "A1"})).unwrap_err();
|
||||
assert!(e.to_string().contains("constants"), "{e}");
|
||||
assert_eq!(code(&s, 0, "A1").as_deref(), Some("2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_to_columns_keeps_a_py_piece_as_text() {
|
||||
let mut s = s();
|
||||
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");
|
||||
assert_eq!(code(&s, 0, "A1"), None, "and it is not a Python cell");
|
||||
assert_eq!(super::v(&s, "B1"), Value::Number(7.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_cells_survive_native_json() {
|
||||
let mut s = s();
|
||||
plant(&mut s, 0, "B2", "x = 1\nx");
|
||||
let wb = s.doc().unwrap().wb.clone();
|
||||
let json = serde_json::to_string(&*wb).unwrap();
|
||||
let back: gridcraft_model::Workbook = serde_json::from_str(&json).unwrap();
|
||||
let cell = back.sheet(0).unwrap().cell(CellRef::parse("B2").unwrap()).unwrap();
|
||||
assert_eq!(cell.py.as_ref().unwrap().code, "x = 1\nx");
|
||||
// Cells written before Python existed have no `py` field.
|
||||
let plain: Cell = serde_json::from_value(json!({"value": {"t": "Number", "v": 2.0}, "formula": null, "style": 0})).unwrap();
|
||||
assert!(plain.py.is_none());
|
||||
assert!(!serde_json::to_string(&plain).unwrap().contains("py"), "no field is written for ordinary cells");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_errors_parse_and_have_error_type_numbers() {
|
||||
use gridcraft_core::CellError;
|
||||
for (text, e) in
|
||||
[("#PYTHON!", CellError::Python), ("#BUSY!", CellError::Busy), ("#TIMEOUT!", CellError::Timeout), ("#BLOCKED!", CellError::Blocked)]
|
||||
{
|
||||
assert_eq!(CellError::parse(text), Some(e));
|
||||
assert_eq!(e.as_str(), text);
|
||||
}
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "=ERROR.TYPE(#BLOCKED!)"})).unwrap();
|
||||
assert_eq!(super::v(&s, "A1"), Value::Number(11.0));
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "=ISERROR(#PYTHON!)"})).unwrap();
|
||||
assert_eq!(super::v(&s, "A2"), Value::Bool(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_to_special_counts_a_python_cell_as_a_formula_not_a_constant() {
|
||||
let mut s = s();
|
||||
plant(&mut s, 0, "A1", "1 + 1");
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "=1+1"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A3", "input": "7"})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "A1"})).unwrap();
|
||||
let r = s.execute("edit.goToSpecial", json!({"kind": "formulas"})).unwrap();
|
||||
assert_eq!(r["count"], 2, "{r}");
|
||||
assert_eq!(r["selection"], "A1:A2", "{r}");
|
||||
s.execute("selection.set", json!({"range": "A1"})).unwrap();
|
||||
let r = s.execute("edit.goToSpecial", json!({"kind": "constants"})).unwrap();
|
||||
assert_eq!(r["count"], 1, "{r}");
|
||||
assert_eq!(r["selection"], "A3", "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_macro_counts_python_code_as_data_and_a_blocked_spill_as_no_write() {
|
||||
let mut s = s();
|
||||
// F2 shows 1, its cached value: a step that enters 1 there still replaces the code.
|
||||
plant(&mut s, 0, "F2", "1");
|
||||
// H2's code shows #BLOCKED!, as any other new code there would.
|
||||
s.execute("python.set", json!({"cell": "H2", "code": "'a'"})).unwrap();
|
||||
// J2's result spills over J2:J3. A step that writes J3 blocks it, which doesn't write J2.
|
||||
s.execute("python.set", json!({"cell": "J2", "code": "[5, 6]"})).unwrap();
|
||||
{
|
||||
let d = s.doc_mut().unwrap();
|
||||
let wb = Arc::make_mut(&mut d.wb);
|
||||
let cell = wb.sheet_mut(0).unwrap().cells.get_mut(CellRef::parse("J2").unwrap()).unwrap();
|
||||
let data = vec![Value::Number(5.0), Value::Number(6.0)];
|
||||
let value = Value::Array(Arc::new(gridcraft_core::Array::new(2, 1, data).unwrap()));
|
||||
Arc::make_mut(cell.py.as_mut().unwrap()).output =
|
||||
Some(gridcraft_model::PyOutput { value, type_name: "list".into(), preview: String::new(), convertible: true });
|
||||
}
|
||||
s.execute("formulas.calculateNow", json!({})).unwrap();
|
||||
assert_eq!(super::v(&s, "J3"), Value::Number(6.0));
|
||||
super::macros::put(
|
||||
&mut s,
|
||||
json!({"name": "Py", "steps": [
|
||||
{"command": "cell.set", "params": {"cell": "F2", "input": "1"}},
|
||||
{"command": "python.set", "params": {"cell": "H2", "code": "'b'"}},
|
||||
{"command": "cell.set", "params": {"cell": "J3", "input": "x"}}
|
||||
]}),
|
||||
);
|
||||
let c = s.execute("macro.check", json!({"name": "Py"})).unwrap();
|
||||
assert_eq!(c["footprint"]["sheets"][0]["cells"], json!(["F2", "H2", "J3"]), "{c}");
|
||||
assert_eq!(c["footprint"]["sheets"][0]["conflicts"], json!(["F2", "H2"]), "{c}");
|
||||
let e = s.execute("macro.run", json!({"name": "Py", "onConflict": "fail"})).unwrap_err().to_string();
|
||||
assert!(e.contains("F2") && e.contains("H2") && !e.contains("J2"), "{e}");
|
||||
assert_eq!(code(&s, 0, "F2").as_deref(), Some("1"), "nothing was changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_edits_a_python_cells_code_and_keeps_it_a_python_cell() {
|
||||
let mut s = s();
|
||||
plant(&mut s, 0, "A1", "x = 1\nx");
|
||||
{
|
||||
let d = s.doc_mut().unwrap();
|
||||
let mut wb = (*d.wb).clone();
|
||||
let cell = wb.sheet_mut(0).unwrap().cells.get_mut(CellRef::parse("A1").unwrap()).unwrap();
|
||||
Arc::make_mut(cell.py.as_mut().unwrap()).object = true;
|
||||
d.wb = Arc::new(wb);
|
||||
}
|
||||
let r = s.execute("edit.replace", json!({"what": "x", "with": "y"})).unwrap();
|
||||
assert_eq!(r["replaced"], 1);
|
||||
assert_eq!(code(&s, 0, "A1").as_deref(), Some("y = 1\ny"));
|
||||
let d = s.doc().unwrap();
|
||||
let cell = d.wb.sheet(0).unwrap().cell(CellRef::parse("A1").unwrap()).unwrap();
|
||||
assert!(cell.py.as_ref().unwrap().object, "the return type is kept");
|
||||
assert_eq!(cell.value, Value::Error(gridcraft_core::CellError::Blocked));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(code(&s, 0, "A1").as_deref(), Some("x = 1\nx"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_leaves_python_code_that_would_no_longer_fit() {
|
||||
let mut s = s();
|
||||
let long = "a".repeat(gridcraft_model::python::MAX_FORMULA_CHARS / 2);
|
||||
plant(&mut s, 0, "A1", &long);
|
||||
let r = s.execute("edit.replace", json!({"what": "a", "with": "bb"})).unwrap();
|
||||
assert_eq!(r["replaced"], 0);
|
||||
assert_eq!(r["skipped"], 1);
|
||||
assert_eq!(code(&s, 0, "A1").as_deref(), Some(long.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hidden_python_cell_on_a_protected_sheet_hides_its_code() {
|
||||
let mut s = s();
|
||||
s.execute("python.set", json!({"cell": "A1", "code": "secret = 1\nsecret"})).unwrap();
|
||||
s.execute("python.set", json!({"cell": "B1", "code": "'shown'"})).unwrap();
|
||||
s.execute("home.formatCells", json!({"range": "A1", "style": {"protection": {"locked": true, "hidden": true}}})).unwrap();
|
||||
let hidden = |s: &Session, a: &str| {
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.active().unwrap();
|
||||
crate::display::formula_hidden(&d.wb, sh, CellRef::parse(a).unwrap())
|
||||
};
|
||||
assert!(!hidden(&s, "A1"), "an unprotected sheet hides nothing");
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
assert!(hidden(&s, "A1"), "Hidden on a protected sheet hides the code, as it hides a formula");
|
||||
assert!(!hidden(&s, "B1"));
|
||||
// Editing it is refused where a formula's would be: the cell is locked.
|
||||
assert!(s.execute("python.set", json!({"cell": "A1", "code": "2"})).is_err());
|
||||
assert_eq!(code(&s, 0, "A1").as_deref(), Some("secret = 1\nsecret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_merge_keeps_only_the_upper_left_cells_python_code() {
|
||||
let mut s = s();
|
||||
for (cmd, row) in [("home.mergeAcross", 1), ("home.mergeCells", 2), ("home.mergeCenter", 3)] {
|
||||
plant(&mut s, 0, &format!("A{row}"), "'kept'");
|
||||
plant(&mut s, 0, &format!("B{row}"), "'merged away'");
|
||||
s.execute(cmd, json!({"range": format!("A{row}:B{row}")})).unwrap();
|
||||
assert_eq!(code(&s, 0, &format!("A{row}")).as_deref(), Some("'kept'"), "{cmd}");
|
||||
assert_eq!(code(&s, 0, &format!("B{row}")), None, "{cmd}: the hidden cell is no Python cell");
|
||||
assert_eq!(super::v(&s, &format!("B{row}")), Value::Empty, "{cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmerging_lets_a_blocked_python_result_spill_again() {
|
||||
let mut s = s();
|
||||
s.execute("python.set", json!({"cell": "A1", "code": "[5, 6]"})).unwrap();
|
||||
{
|
||||
let d = s.doc_mut().unwrap();
|
||||
let wb = Arc::make_mut(&mut d.wb);
|
||||
let cell = wb.sheet_mut(0).unwrap().cells.get_mut(CellRef::parse("A1").unwrap()).unwrap();
|
||||
let value = Value::Array(Arc::new(gridcraft_core::Array::new(2, 1, vec![Value::Number(5.0), Value::Number(6.0)]).unwrap()));
|
||||
Arc::make_mut(cell.py.as_mut().unwrap()).output =
|
||||
Some(gridcraft_model::PyOutput { value, type_name: "list".into(), preview: String::new(), convertible: true });
|
||||
}
|
||||
s.execute("formulas.calculateNow", json!({})).unwrap();
|
||||
assert_eq!(super::v(&s, "A2"), Value::Number(6.0));
|
||||
s.execute("home.mergeCells", json!({"range": "A2:B2"})).unwrap();
|
||||
assert_eq!(super::v(&s, "A1"), Value::Error(gridcraft_core::CellError::Spill), "a merged cell blocks the spill");
|
||||
s.execute("home.unmergeCells", json!({"range": "A2:B2"})).unwrap();
|
||||
assert_eq!((super::v(&s, "A1"), super::v(&s, "A2")), (Value::Number(5.0), Value::Number(6.0)), "it spills again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_model_asks_before_it_writes_over_a_python_cell_that_shows_nothing() {
|
||||
let mut s = s();
|
||||
for (cell, input) in [("B1", "=5*A1+4*A2"), ("B2", "=6*A1+4*A2")] {
|
||||
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
|
||||
}
|
||||
// H2 is a Python cell whose cached value is empty: its code is still content.
|
||||
{
|
||||
let d = s.doc_mut().unwrap();
|
||||
let wb = Arc::make_mut(&mut d.wb);
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
sh.set_cell(
|
||||
CellRef::parse("H2").unwrap(),
|
||||
Cell { value: Value::Empty, formula: None, style: Default::default(), py: Some(Arc::new(PyCode::new("None"))) },
|
||||
);
|
||||
}
|
||||
let model = json!({"objective": "B1", "variables": "A1:A2", "constraints": ["B2 <= 24"]});
|
||||
let e = s.execute("data.solverSaveModel", json!({"to": "H1", "model": model})).unwrap_err().to_string();
|
||||
assert!(e.contains(crate::cmd::analysis::OVERWRITE_ERROR) && e.contains("H2"), "{e}");
|
||||
assert_eq!(code(&s, 0, "H2").as_deref(), Some("None"), "nothing was written");
|
||||
}
|
||||
|
||||
/// A1 is a Python cell whose kept result `[5, 6]` spills over A1:A2, and B1 reads A2.
|
||||
pub(super) fn spill_with_a_reader() -> crate::Session {
|
||||
let mut s = s();
|
||||
s.execute("python.set", json!({"cell": "A1", "code": "[5, 6]"})).unwrap();
|
||||
{
|
||||
let d = s.doc_mut().unwrap();
|
||||
let wb = Arc::make_mut(&mut d.wb);
|
||||
let cell = wb.sheet_mut(0).unwrap().cells.get_mut(CellRef::parse("A1").unwrap()).unwrap();
|
||||
let value = Value::Array(Arc::new(gridcraft_core::Array::new(2, 1, vec![Value::Number(5.0), Value::Number(6.0)]).unwrap()));
|
||||
Arc::make_mut(cell.py.as_mut().unwrap()).output =
|
||||
Some(gridcraft_model::PyOutput { value, type_name: "list".into(), preview: String::new(), convertible: true });
|
||||
}
|
||||
s.execute("formulas.calculateNow", json!({})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "=SUM(A2)"})).unwrap();
|
||||
assert_eq!(super::v(&s, "B1"), Value::Number(6.0));
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_python_code_recalculates_the_readers_of_its_old_spill() {
|
||||
let mut s = spill_with_a_reader();
|
||||
s.execute("python.set", json!({"cell": "A1", "code": "[7, 8]"})).unwrap();
|
||||
assert_eq!(super::v(&s, "A2"), Value::Empty, "new code has no result yet");
|
||||
assert_eq!(super::v(&s, "B1"), Value::Number(0.0), "B1 read the old spill");
|
||||
// The same code again keeps its result, and its spill.
|
||||
let mut s = spill_with_a_reader();
|
||||
s.execute("python.set", json!({"cell": "A1", "code": "[5, 6]"})).unwrap();
|
||||
assert_eq!((super::v(&s, "A2"), super::v(&s, "B1")), (Value::Number(6.0), Value::Number(6.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_in_python_code_recalculates_the_readers_of_its_old_spill() {
|
||||
let mut s = spill_with_a_reader();
|
||||
let r = s.execute("edit.replace", json!({"what": "[5,", "with": "[7,"})).unwrap();
|
||||
assert_eq!(r["replaced"], 1);
|
||||
assert_eq!(super::v(&s, "A2"), Value::Empty);
|
||||
assert_eq!(super::v(&s, "B1"), Value::Number(0.0), "B1 read the old spill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_pasting_over_or_blocking_a_python_spill_recalculates_its_readers() {
|
||||
let mut s = spill_with_a_reader();
|
||||
s.execute("edit.clearContents", json!({"range": "A1"})).unwrap();
|
||||
assert_eq!(super::v(&s, "B1"), Value::Number(0.0), "clear");
|
||||
let mut s = spill_with_a_reader();
|
||||
s.execute("cell.set", json!({"cell": "D1", "input": "9"})).unwrap();
|
||||
s.execute("edit.copy", json!({"range": "D1"})).unwrap();
|
||||
s.execute("edit.paste", json!({"at": "A1"})).unwrap();
|
||||
assert_eq!(super::v(&s, "B1"), Value::Number(0.0), "paste");
|
||||
let mut s = spill_with_a_reader();
|
||||
s.execute("home.mergeCells", json!({"range": "A2:A3"})).unwrap();
|
||||
assert_eq!(super::v(&s, "A1"), Value::Error(gridcraft_core::CellError::Spill));
|
||||
assert_eq!(super::v(&s, "B1"), Value::Number(0.0), "merge");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(super::v(&s, "B1"), Value::Number(6.0), "undo");
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(super::v(&s, "B1"), Value::Number(0.0), "redo");
|
||||
}
|
||||
|
||||
/// `prefix` and a comment padded to the longest code that still fits in one cell.
|
||||
fn at_the_limit(prefix: &str) -> String {
|
||||
let with = |n: usize| format!("{prefix}\n#{}", "x".repeat(n));
|
||||
let (mut lo, mut hi) = (0, gridcraft_model::python::MAX_FORMULA_CHARS);
|
||||
while lo < hi {
|
||||
let mid = (lo + hi).div_ceil(2);
|
||||
if PyCode::new(&with(mid)).fits() { lo = mid } else { hi = mid - 1 }
|
||||
}
|
||||
with(lo)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_that_would_make_python_code_too_long_is_refused() {
|
||||
let mut s = s();
|
||||
s.execute("home.insertSheet", json!({})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
let other = s.doc().unwrap().wb.sheet(1).unwrap().name.clone();
|
||||
let refused = |e: crate::EngineError, cell: &str| {
|
||||
let e = e.to_string();
|
||||
assert!(e.contains(&format!("!{cell} longer than Excel's 8192-character")) && e.contains("nothing was changed"), "{e}");
|
||||
};
|
||||
// A sheet rename that lengthens a reference.
|
||||
let a1 = at_the_limit(&format!("a = xl(\"{other}!B2\")"));
|
||||
plant(&mut s, 0, "A1", &a1);
|
||||
refused(s.execute("sheet.rename", json!({"sheet": 1, "name": "A much longer sheet name"})).unwrap_err(), "A1");
|
||||
assert_eq!(code(&s, 0, "A1").unwrap(), a1);
|
||||
assert_eq!(s.doc().unwrap().wb.sheet(1).unwrap().name, other);
|
||||
// One of the same length is made.
|
||||
let same = "Z".repeat(other.chars().count());
|
||||
s.execute("sheet.rename", json!({"sheet": 1, "name": same})).unwrap();
|
||||
assert_eq!(code(&s, 0, "A1").unwrap(), a1.replace(&other, &same));
|
||||
// Code short of the limit still follows each edit.
|
||||
plant(&mut s, 0, "A7", "d = xl(\"Z1\")");
|
||||
s.execute("selection.set", json!({"range": "B10"})).unwrap();
|
||||
s.execute("home.insertColumns", json!({"cols": "Y:Y"})).unwrap();
|
||||
assert_eq!(code(&s, 0, "A7").unwrap(), "d = xl(\"AA1\")");
|
||||
// An inserted column that turns Z1 into AA1.
|
||||
let a3 = at_the_limit("b = xl(\"Z1\")");
|
||||
plant(&mut s, 0, "A3", &a3);
|
||||
s.execute("cell.set", json!({"cell": "D1", "input": "kept"})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "C1"})).unwrap();
|
||||
refused(s.execute("home.insertColumns", json!({"cols": "C:C"})).unwrap_err(), "A3");
|
||||
assert_eq!(code(&s, 0, "A3").unwrap(), a3);
|
||||
assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().value(CellRef::parse("D1").unwrap()), Value::text("kept"));
|
||||
// A cut to another sheet, which qualifies B9 with the source sheet.
|
||||
let a5 = at_the_limit("c = xl(\"B9\")");
|
||||
plant(&mut s, 0, "A5", &a5);
|
||||
s.execute("edit.cut", json!({"range": "A5"})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 1})).unwrap();
|
||||
refused(s.execute("edit.paste", json!({"at": "C1"})).unwrap_err(), "C1");
|
||||
assert_eq!(code(&s, 0, "A5").unwrap(), a5);
|
||||
assert_eq!(code(&s, 1, "C1"), None);
|
||||
}
|
||||
|
||||
/// Makes `cell` a Python cell whose cached value is empty, as a file's `PY` formula with no cached
|
||||
/// value reads.
|
||||
fn plant_empty(s: &mut Session, cell: &str) {
|
||||
plant(s, 0, cell, "x = 1");
|
||||
let d = s.doc_mut().unwrap();
|
||||
let mut wb = (*d.wb).clone();
|
||||
if let Some(c) = wb.sheet_mut(0).unwrap().cells.get_mut(CellRef::parse(cell).unwrap()) {
|
||||
c.value = Value::Empty;
|
||||
}
|
||||
d.wb = Arc::new(wb);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_code_with_an_empty_value_counts_as_content() {
|
||||
// A report or a PivotTable asks before replacing it.
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Sales"], ["East", 1], ["West", 2], ["East", 3]]})).unwrap();
|
||||
plant_empty(&mut s, "D2");
|
||||
let report = json!({"tool": "Descriptive Statistics", "range": "B1:B4", "labels": true, "output": "D1"});
|
||||
assert!(s.execute("data.analysisToolPak", report).is_err(), "the report asks for overwrite");
|
||||
plant_empty(&mut s, "H4");
|
||||
let pivot = s.execute("insert.pivotTable", json!({"destination": "Sheet1!H3", "rows": ["Region"], "values": ["Sales"]}));
|
||||
assert!(pivot.is_err_and(|e| e.to_string().contains("already data")));
|
||||
assert_eq!((code(&s, 0, "D2"), code(&s, 0, "H4")), (Some("x = 1".into()), Some("x = 1".into())), "the code is kept");
|
||||
// Cells aren't pushed off the sheet over it.
|
||||
plant_empty(&mut s, "K1048576");
|
||||
assert!(s.execute("home.insertCells", json!({"range": "K1", "shift": "down"})).is_err());
|
||||
assert_eq!(code(&s, 0, "K1048576"), Some("x = 1".into()));
|
||||
// A table without a header row doesn't push it off the sheet either.
|
||||
s.execute("range.setValues", json!({"range": "X1", "values": [[1], [2]]})).unwrap();
|
||||
plant_empty(&mut s, "X1048576");
|
||||
assert!(s.execute("insert.table", json!({"range": "X1:X2", "header": false})).is_err());
|
||||
assert_eq!(code(&s, 0, "X1048576"), Some("x = 1".into()));
|
||||
// It is part of the data region.
|
||||
s.execute("range.setValues", json!({"range": "P1", "values": [[1], [""], [3]]})).unwrap();
|
||||
plant_empty(&mut s, "P2");
|
||||
let sh = s.doc().unwrap().wb.sheet(0).unwrap().clone();
|
||||
assert_eq!(crate::selection::current_region(&sh, CellRef::parse("P1").unwrap()).a1(), "P1:P3");
|
||||
// Pasted into part of a legacy array, it is refused like any content.
|
||||
s.execute("selection.set", json!({"range": "R1:R3"})).unwrap();
|
||||
s.execute("cell.set", json!({"input": "=5", "array": true})).unwrap();
|
||||
plant_empty(&mut s, "T1");
|
||||
s.execute("edit.copy", json!({"range": "T1"})).unwrap();
|
||||
assert!(s.execute("edit.paste", json!({"at": "R2"})).is_err_and(|e| e.to_string().contains("part of an array")));
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
//! The python.* commands.
|
||||
|
||||
use gridcraft_core::{CellError, Value};
|
||||
use serde_json::json;
|
||||
|
||||
use super::{s, v};
|
||||
|
||||
fn set(s: &mut crate::Session, cell: &str, code: &str) -> serde_json::Value {
|
||||
s.execute("python.set", json!({"cell": cell, "code": code})).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_stores_the_code_and_blocks_the_cell_until_it_can_run() {
|
||||
let mut s = s();
|
||||
let r = set(&mut s, "B1", "xl('A1') + 1");
|
||||
assert_eq!(r["cell"], "B1");
|
||||
assert_eq!(v(&s, "B1"), Value::Error(CellError::Blocked));
|
||||
let got = s.execute("cell.get", json!({"cell": "B1"})).unwrap();
|
||||
assert_eq!(got["python"]["code"], "xl('A1') + 1");
|
||||
assert_eq!(got["python"]["output"], "value");
|
||||
assert_eq!(got["input"], "xl('A1') + 1");
|
||||
// The active cell by default.
|
||||
s.execute("selection.set", json!({"cell": "D4"})).unwrap();
|
||||
s.execute("python.set", json!({"code": "2"})).unwrap();
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "D4"})).unwrap()["python"]["code"], "2");
|
||||
// One undo step takes the cell back.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.execute("cell.get", json!({"cell": "D4"})).unwrap()["python"].is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_code_keeps_its_cached_result() {
|
||||
let mut s = s();
|
||||
set(&mut s, "A1", "21 * 2");
|
||||
// As a file would have cached it.
|
||||
{
|
||||
let d = s.doc_mut().unwrap();
|
||||
let wb = std::sync::Arc::make_mut(&mut d.wb);
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
let cell = sh.cells.get_mut(gridcraft_core::CellRef::new(0, 0)).unwrap();
|
||||
let py = std::sync::Arc::make_mut(cell.py.as_mut().unwrap());
|
||||
py.output = Some(gridcraft_model::PyOutput { value: Value::Number(42.0), type_name: "int".into(), preview: "42".into(), convertible: true });
|
||||
cell.value = Value::Number(42.0);
|
||||
}
|
||||
set(&mut s, "A1", "21 * 2");
|
||||
assert_eq!(v(&s, "A1"), Value::Number(42.0), "same code, same result");
|
||||
s.execute("python.set", json!({"cell": "A1", "code": "21 * 2", "output": "object"})).unwrap();
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["python"]["output"], "object");
|
||||
set(&mut s, "A1", "21 * 3");
|
||||
assert_eq!(v(&s, "A1"), Value::Error(CellError::Blocked), "new code has no result yet");
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["python"]["output"], "object", "the return type stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_refuses_bad_params_long_code_headers_and_protected_cells() {
|
||||
let mut s = s();
|
||||
for p in [
|
||||
json!({"cell": "A1"}),
|
||||
json!({"cell": "A1", "code": 5}),
|
||||
json!({"cell": "not a cell", "code": "1"}),
|
||||
json!({"cell": "A1", "code": "1", "output": "sideways"}),
|
||||
] {
|
||||
assert!(s.execute("python.set", p.clone()).is_err(), "{p}");
|
||||
}
|
||||
// The limit is the stored PY(…) formula's 8,192 characters.
|
||||
assert!(s.execute("python.set", json!({"cell": "A1", "code": "y".repeat(8_080)})).is_err());
|
||||
s.execute("python.set", json!({"cell": "A1", "code": "y".repeat(8_079)})).unwrap();
|
||||
let quoted = format!("{}{}", "\"".repeat(1_200), "y".repeat(5_800));
|
||||
let e = s.execute("python.set", json!({"cell": "A2", "code": quoted})).unwrap_err();
|
||||
assert!(e.to_string().contains("8192") || e.to_string().contains("8,192"), "{e}");
|
||||
// A table header is the column's name.
|
||||
s.execute("range.setValues", json!({"range": "C1", "values": [["Name", "Score"], ["a", 1]]})).unwrap();
|
||||
s.execute("insert.table", json!({"range": "C1:D2", "headers": true})).unwrap();
|
||||
assert!(s.execute("python.set", json!({"cell": "D1", "code": "1"})).is_err());
|
||||
assert_eq!(v(&s, "D1"), Value::text("Score"));
|
||||
set(&mut s, "D2", "1");
|
||||
// A locked cell on a protected sheet refuses it.
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
let e = s.execute("python.set", json!({"cell": "F1", "code": "1"})).unwrap_err();
|
||||
assert!(e.to_string().contains("protected"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_over_a_spilling_formula_takes_the_spill_away() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)"})).unwrap();
|
||||
assert_eq!(v(&s, "A3"), Value::Number(3.0));
|
||||
set(&mut s, "A1", "[1, 2, 3]");
|
||||
assert_eq!(v(&s, "A3"), Value::Empty);
|
||||
assert!(s.doc().unwrap().wb.sheet(0).unwrap().spill_ranges.is_empty());
|
||||
}
|
||||
|
||||
/// A cached result as a file would have kept it on the cell: `[[1], [2], [3]]`, a list.
|
||||
fn cache_list(s: &mut crate::Session, cell: &str) {
|
||||
let at = gridcraft_core::CellRef::parse(cell).unwrap();
|
||||
let d = s.doc_mut().unwrap();
|
||||
let wb = std::sync::Arc::make_mut(&mut d.wb);
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
let py = std::sync::Arc::make_mut(sh.cells.get_mut(at).unwrap().py.as_mut().unwrap());
|
||||
let arr = gridcraft_core::Array::new(3, 1, vec![1.0.into(), 2.0.into(), 3.0.into()]).map(Value::from).unwrap();
|
||||
py.output = Some(gridcraft_model::PyOutput { value: arr, type_name: "list".into(), preview: "[1, 2, 3]".into(), convertible: true });
|
||||
}
|
||||
|
||||
/// A1 holds `[1, 2, 3]` with its cached list spilled over A1:A3 as Excel values; B1 reads A3.
|
||||
fn cached_spill_with_a_reader() -> crate::Session {
|
||||
let mut s = s();
|
||||
set(&mut s, "A1", "[1, 2, 3]");
|
||||
cache_list(&mut s, "A1");
|
||||
s.execute("formulas.calculateNow", json!({})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "=SUM(A3)"})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Number(1.0));
|
||||
assert_eq!(v(&s, "B1"), Value::Number(3.0));
|
||||
s
|
||||
}
|
||||
|
||||
fn output_of(s: &mut crate::Session, cell: &str) -> serde_json::Value {
|
||||
s.execute("cell.get", json!({"cell": cell})).unwrap()["python"]["output"].clone()
|
||||
}
|
||||
|
||||
fn undo_depth(s: &mut crate::Session) -> Option<usize> {
|
||||
s.execute("history.list", json!({})).unwrap()["undo"].as_array().map(Vec::len)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_switches_the_return_type_and_drops_a_result_of_the_other_type() {
|
||||
let mut s = cached_spill_with_a_reader();
|
||||
// The mode it already has changes nothing and adds no undo step.
|
||||
let undo = undo_depth(&mut s);
|
||||
let r = s.execute("python.output", json!({"cell": "A1", "mode": "value"})).unwrap();
|
||||
assert_eq!(r["output"], "value");
|
||||
assert_eq!(undo_depth(&mut s), undo);
|
||||
assert_eq!(v(&s, "A3"), Value::Number(3.0), "the kept result still spills");
|
||||
// Without `mode` it switches. The cached list is an Excel value's result: as with python.set
|
||||
// and a new return type, the cell has no result until it runs, and its old spill goes with
|
||||
// its readers recalculated.
|
||||
s.execute("selection.set", json!({"cell": "A1"})).unwrap();
|
||||
let r = s.execute("python.output", json!({})).unwrap();
|
||||
assert_eq!(r["output"], "object");
|
||||
assert_eq!(v(&s, "A1"), Value::Error(CellError::Blocked));
|
||||
assert_eq!(v(&s, "A3"), Value::Empty, "the old spill goes");
|
||||
assert_eq!(v(&s, "B1"), Value::Number(0.0), "and what read it is recalculated");
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["python"]["code"], "[1, 2, 3]", "the code stays");
|
||||
// The same as python.set with the same code and the other return type.
|
||||
let mut by_set = cached_spill_with_a_reader();
|
||||
by_set.execute("python.set", json!({"cell": "A1", "code": "[1, 2, 3]", "output": "object"})).unwrap();
|
||||
for c in ["A1", "A3", "B1"] {
|
||||
assert_eq!(v(&s, c), v(&by_set, c), "{c}");
|
||||
}
|
||||
assert_eq!(output_of(&mut s, "A1"), output_of(&mut by_set, "A1"));
|
||||
// Switching back brings no result back: the dropped one belonged to the old type.
|
||||
s.execute("python.output", json!({"cell": "A1", "mode": "value"})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Error(CellError::Blocked));
|
||||
assert_eq!(v(&s, "A3"), Value::Empty);
|
||||
// Each switch is one undo step, and undoing the first brings the result and its spill back.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(output_of(&mut s, "A1"), "object");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(output_of(&mut s, "A1"), "value");
|
||||
assert_eq!(v(&s, "A1"), Value::Number(1.0));
|
||||
assert_eq!(v(&s, "A3"), Value::Number(3.0));
|
||||
assert_eq!(v(&s, "B1"), Value::Number(3.0));
|
||||
// A cell with no result keeps showing #BLOCKED!.
|
||||
set(&mut s, "C1", "x = 1");
|
||||
s.execute("python.output", json!({"cell": "C1"})).unwrap();
|
||||
assert_eq!(v(&s, "C1"), Value::Error(CellError::Blocked));
|
||||
assert_eq!(output_of(&mut s, "C1"), "object");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_refuses_table_headers_and_locked_cells_as_set_does() {
|
||||
let mut s = s();
|
||||
// A Python cell in a table's header row (as a file could hold one): python.set refuses to
|
||||
// put code there, and python.output refuses to change it.
|
||||
s.execute("range.setValues", json!({"range": "C1", "values": [["Name", "Score"], ["a", 1]]})).unwrap();
|
||||
s.execute("insert.table", json!({"range": "C1:D2", "headers": true})).unwrap();
|
||||
{
|
||||
let d = s.doc_mut().unwrap();
|
||||
let wb = std::sync::Arc::make_mut(&mut d.wb);
|
||||
let cell = wb.sheet_mut(0).unwrap().cells.get_mut(gridcraft_core::CellRef::parse("D1").unwrap()).unwrap();
|
||||
cell.py = Some(std::sync::Arc::new(gridcraft_model::PyCode::new("'Score'")));
|
||||
}
|
||||
let e = s.execute("python.output", json!({"cell": "D1"})).unwrap_err();
|
||||
assert!(e.to_string().contains("table header"), "{e}");
|
||||
assert_eq!(output_of(&mut s, "D1"), "value");
|
||||
// A locked cell on a protected sheet refuses it; an unlocked one formatted Hidden takes it,
|
||||
// as it takes python.set.
|
||||
set(&mut s, "F1", "1");
|
||||
set(&mut s, "G1", "2");
|
||||
s.execute("home.formatCells", json!({"range": "G1", "style": {"protection": {"locked": false, "hidden": true}}})).unwrap();
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
let e = s.execute("python.output", json!({"cell": "F1"})).unwrap_err();
|
||||
assert!(e.to_string().contains("protected"), "{e}");
|
||||
assert_eq!(output_of(&mut s, "F1"), "value");
|
||||
s.execute("python.set", json!({"cell": "G1", "code": "2"})).unwrap();
|
||||
s.execute("python.output", json!({"cell": "G1"})).unwrap();
|
||||
assert_eq!(output_of(&mut s, "G1"), "object");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_refuses_cells_without_python_and_bad_modes() {
|
||||
let mut s = s();
|
||||
// Empty params: the active cell, which holds no Python code.
|
||||
let e = s.execute("python.output", json!({})).unwrap_err();
|
||||
assert!(e.to_string().contains("isn't a Python cell"), "{e}");
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "=1+1"})).unwrap();
|
||||
assert!(s.execute("python.output", json!({"cell": "B1"})).is_err());
|
||||
set(&mut s, "A1", "1");
|
||||
for p in [json!({"cell": "A1", "mode": "sideways"}), json!({"cell": "A1", "mode": 1}), json!({"cell": "nowhere"})] {
|
||||
assert!(s.execute("python.output", p.clone()).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["python"]["output"], "value");
|
||||
// python.set refuses a return type that isn't text too, rather than ignoring it.
|
||||
assert!(s.execute("python.set", json!({"cell": "A1", "code": "1", "output": true})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recorded_switch_keeps_the_return_type_it_set() {
|
||||
// python.output without `mode` switches, so a recording keeps the return type the switch set
|
||||
// (as Bold records `on`): played back on a cell that already has it, nothing switches back.
|
||||
let mut s = s();
|
||||
set(&mut s, "A1", "1");
|
||||
s.execute("macro.record", json!({"relative": false})).unwrap();
|
||||
s.execute("python.output", json!({"cell": "A1"})).unwrap();
|
||||
s.execute("macro.record", json!({"stop": true, "name": "Rec"})).unwrap();
|
||||
assert_eq!(output_of(&mut s, "A1"), "object");
|
||||
let m = s.execute("macro.get", json!({"name": "Rec"})).unwrap();
|
||||
let m = crate::macros::parse_macro(&m).unwrap();
|
||||
let steps = crate::macros::command_steps(&m.steps);
|
||||
let step = steps.iter().find(|(_, id, _)| *id == "python.output").map(|(_, _, p)| (*p).clone());
|
||||
assert_eq!(step.and_then(|p| p.get("mode").cloned()), Some(json!("object")));
|
||||
for _ in 0..2 {
|
||||
s.execute("macro.run", json!({"name": "Rec", "onConflict": "overwrite"})).unwrap();
|
||||
assert_eq!(output_of(&mut s, "A1"), "object");
|
||||
}
|
||||
// A run inside a script is recorded the same way.
|
||||
s.execute("automate.newScript", json!({"name": "Switch", "commands": [{"command": "python.output", "params": {"cell": "A1"}}]})).unwrap();
|
||||
s.execute("macro.record", json!({"relative": false})).unwrap();
|
||||
s.execute("automate.runScript", json!({"name": "Switch"})).unwrap();
|
||||
s.execute("macro.record", json!({"stop": true, "name": "Rec2"})).unwrap();
|
||||
assert_eq!(output_of(&mut s, "A1"), "value");
|
||||
for _ in 0..2 {
|
||||
s.execute("macro.run", json!({"name": "Rec2", "onConflict": "overwrite"})).unwrap();
|
||||
assert_eq!(output_of(&mut s, "A1"), "value");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,744 @@
|
||||
//! Python cells in files: native JSON, and xlsx as `_xlfn._xlws.PY(index,0|1)` with the code in
|
||||
//! the Python part ([MS-XLSX] 2.1.23, 2.3.10) and cached values. Nothing here runs Python: results
|
||||
//! are cached ones, as a file would hold them.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value};
|
||||
use gridcraft_model::{Cell, PyCode, PyOutput, Workbook};
|
||||
use serde_json::json;
|
||||
|
||||
use super::{s, v};
|
||||
use crate::{Session, Trust};
|
||||
|
||||
fn py(s: &mut Session, cell: &str, code: &str) {
|
||||
s.execute("python.set", json!({"cell": cell, "code": code})).unwrap();
|
||||
}
|
||||
|
||||
/// Gives a Python cell a result, as running it (or a file's cache) would.
|
||||
fn cache(s: &mut Session, cell: &str, value: Value, type_name: &str) {
|
||||
let d = s.doc_mut().unwrap();
|
||||
let wb = Arc::make_mut(&mut d.wb);
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
let c = sh.cells.get_mut(CellRef::parse(cell).unwrap()).unwrap();
|
||||
let p = Arc::make_mut(c.py.as_mut().unwrap());
|
||||
p.output = Some(PyOutput { value, type_name: type_name.into(), preview: String::new(), convertible: true });
|
||||
}
|
||||
|
||||
fn array(rows: usize, cols: usize, data: Vec<Value>) -> Value {
|
||||
Value::Array(Arc::new(Array::new(rows, cols, data).unwrap()))
|
||||
}
|
||||
|
||||
fn save(s: &mut Session, format: &str) -> Vec<u8> {
|
||||
let r = s.execute("file.saveBytes", json!({"format": format})).unwrap();
|
||||
crate::io::base64_decode(r["base64"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, name: &str, bytes: &[u8]) {
|
||||
s.execute("file.open", json!({"name": name, "base64": crate::io::base64_encode(bytes)})).unwrap();
|
||||
}
|
||||
|
||||
fn part(bytes: &[u8], name: &str) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
z.by_name(name).unwrap().read_to_string(&mut xml).unwrap();
|
||||
xml
|
||||
}
|
||||
|
||||
fn sheet_xml(bytes: &[u8]) -> String {
|
||||
part(bytes, "xl/worksheets/sheet1.xml")
|
||||
}
|
||||
|
||||
/// The package with `edit` applied to each XML part (by name), and `add` added.
|
||||
fn with_parts(bytes: &[u8], edit: impl Fn(&str, &str) -> String, add: &[(&str, &str)]) -> Vec<u8> {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).unwrap();
|
||||
let name = f.name().to_string();
|
||||
let mut data = Vec::new();
|
||||
f.read_to_end(&mut data).unwrap();
|
||||
if let Ok(text) = std::str::from_utf8(&data) {
|
||||
data = edit(&name, text).into_bytes();
|
||||
}
|
||||
out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
out.write_all(&data).unwrap();
|
||||
}
|
||||
for (name, text) in add {
|
||||
out.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
out.write_all(text.as_bytes()).unwrap();
|
||||
}
|
||||
out.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// The package with `edit` applied to the first sheet's XML.
|
||||
fn with_sheet_xml(bytes: &[u8], edit: impl Fn(&str) -> String) -> Vec<u8> {
|
||||
with_parts(bytes, |name, text| if name == "xl/worksheets/sheet1.xml" { edit(text) } else { text.to_string() }, &[])
|
||||
}
|
||||
|
||||
/// `xml` with its `<sheetData>` replaced by `data`.
|
||||
fn replace_sheet_data(xml: &str, data: &str) -> String {
|
||||
match (xml.find("<sheetData"), xml.find("</sheetData>"), xml.find("<sheetData/>")) {
|
||||
(Some(a), Some(b), _) => format!("{}{data}{}", &xml[..a], &xml[b + "</sheetData>".len()..]),
|
||||
(_, _, Some(a)) => format!("{}{data}{}", &xml[..a], &xml[a + "<sheetData/>".len()..]),
|
||||
_ => panic!("no sheetData in {xml}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// An empty workbook's package with `data` as its first sheet's cells and, when `scripts` is
|
||||
/// given, a Python part holding them (the relationship and the content type included), as
|
||||
/// [MS-XLSX] 2.1.23 describes it.
|
||||
fn excel_shaped(data: &str, scripts: Option<&[&str]>) -> Vec<u8> {
|
||||
let mut s = s();
|
||||
let plain = save(&mut s, "xlsx");
|
||||
let Some(scripts) = scripts else { return with_sheet_xml(&plain, |xml| replace_sheet_data(xml, data)) };
|
||||
let codes: String = scripts.iter().map(|c| format!("<pythonScript><code>{c}</code></pythonScript>")).collect();
|
||||
let python = format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<python xmlns=\"http://schemas.microsoft.com/office/spreadsheetml/2023/python\"><environmentDefinition id=\"{{00000000-0000-0000-0000-000000000000}}\"/><pythonScripts>{codes}</pythonScripts></python>"
|
||||
);
|
||||
const REL: &str =
|
||||
"<Relationship Id=\"rIdPy\" Type=\"http://schemas.microsoft.com/office/2023/09/relationships/Python\" Target=\"python/python1.xml\"/>";
|
||||
const TYPE: &str = "<Override PartName=\"/xl/python/python1.xml\" ContentType=\"application/vnd.ms-excel.python+xml\"/>";
|
||||
let edit = |name: &str, text: &str| match name {
|
||||
"xl/worksheets/sheet1.xml" => replace_sheet_data(text, data),
|
||||
"xl/_rels/workbook.xml.rels" => text.replace("</Relationships>", &format!("{REL}</Relationships>")),
|
||||
"[Content_Types].xml" => text.replace("</Types>", &format!("{TYPE}</Types>")),
|
||||
_ => text.to_string(),
|
||||
};
|
||||
with_parts(&plain, edit, &[("xl/python/python1.xml", &python)])
|
||||
}
|
||||
|
||||
fn code(s: &Session, cell: &str) -> Option<PyCode> {
|
||||
s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse(cell).unwrap()).and_then(|c| c.py.as_deref()).cloned()
|
||||
}
|
||||
|
||||
/// A workbook with a value, a formula over a Python cell, a spilling table, an object, long code
|
||||
/// with quotes and line breaks, and an error.
|
||||
fn build() -> (Session, String) {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "21"})).unwrap();
|
||||
py(&mut s, "B1", "xl('A1') * 2");
|
||||
cache(&mut s, "B1", Value::Number(42.0), "int");
|
||||
s.execute("cell.set", json!({"cell": "A5", "input": "=B1+1"})).unwrap();
|
||||
py(&mut s, "C1", "[[1, 'two'], [3, \"four\"]]");
|
||||
cache(&mut s, "C1", array(2, 2, vec![1.0.into(), Value::text("two"), 3.0.into(), Value::text("four")]), "list");
|
||||
s.execute("python.set", json!({"cell": "E1", "code": "[1, 2]", "output": "object"})).unwrap();
|
||||
cache(&mut s, "E1", array(2, 1, vec![1.0.into(), 2.0.into()]), "list");
|
||||
let long = format!("# {}\ntext = \"say \\\"hi\\\"\"\nlen(text)", "x".repeat(7_900));
|
||||
assert!(PyCode::new(&long).fits());
|
||||
py(&mut s, "F1", &long);
|
||||
cache(&mut s, "F1", Value::Number(8.0), "int");
|
||||
py(&mut s, "G1", "1/0");
|
||||
{
|
||||
let d = s.doc_mut().unwrap();
|
||||
let sh = Arc::make_mut(&mut d.wb).sheet_mut(0).unwrap();
|
||||
sh.cells.get_mut(CellRef::parse("G1").unwrap()).unwrap().value = Value::Error(CellError::Python);
|
||||
}
|
||||
s.execute("formulas.calculateNow", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "B1"), Value::Number(42.0));
|
||||
assert_eq!(v(&s, "A5"), Value::Number(43.0));
|
||||
assert_eq!(v(&s, "D2"), Value::text("four"));
|
||||
assert_eq!(v(&s, "E1"), Value::text("list"), "a Python object shows its type");
|
||||
assert_eq!(v(&s, "E2"), Value::Empty, "and doesn't spill");
|
||||
(s, long)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xlsx_stores_the_code_in_the_python_part_with_cached_values() {
|
||||
let (mut s, long) = build();
|
||||
let bytes = save(&mut s, "xlsx");
|
||||
let xml = sheet_xml(&bytes);
|
||||
// The formula names the cell's script by index, in the order the cells are written: B1, C1,
|
||||
// E1, F1, G1.
|
||||
assert!(xml.contains("<f>_xlfn._xlws.PY(0,0)</f><v>42</v>"), "{xml}");
|
||||
assert!(xml.contains(">_xlfn._xlws.PY(2,1)</f>"), "the Python object cell has return type 1: {xml}");
|
||||
assert!(!xml.contains("xl('A1')"), "the code is not in the sheet");
|
||||
let python = part(&bytes, "xl/python.xml");
|
||||
assert!(
|
||||
python.contains("<python xmlns=\"http://schemas.microsoft.com/office/spreadsheetml/2023/python\"><pythonScripts><pythonScript><code>xl('A1') * 2</code></pythonScript>"),
|
||||
"{python}"
|
||||
);
|
||||
assert_eq!(python.matches("<pythonScript>").count(), 5);
|
||||
assert!(
|
||||
part(&bytes, "[Content_Types].xml").contains("<Override PartName=\"/xl/python.xml\" ContentType=\"application/vnd.ms-excel.python+xml\"/>")
|
||||
);
|
||||
assert!(
|
||||
part(&bytes, "xl/_rels/workbook.xml.rels")
|
||||
.contains("Type=\"http://schemas.microsoft.com/office/2023/09/relationships/Python\" Target=\"python.xml\"")
|
||||
);
|
||||
assert!(xml.contains("t=\"array\" ref=\"C1:D2\""), "the spill is saved like a dynamic array's: {xml}");
|
||||
assert!(xml.contains("#VALUE!") && !xml.contains("#PYTHON!"), "Python errors are cached as #VALUE!");
|
||||
|
||||
open(&mut s, "book.xlsx", &bytes);
|
||||
assert_eq!(s.doc().unwrap().trust, Trust::FromFile);
|
||||
// Cached values show, spills included, and the code is back.
|
||||
assert_eq!(v(&s, "B1"), Value::Number(42.0));
|
||||
assert_eq!(v(&s, "A5"), Value::Number(43.0));
|
||||
assert_eq!(
|
||||
(v(&s, "C1"), v(&s, "D1"), v(&s, "C2"), v(&s, "D2")),
|
||||
(Value::Number(1.0), Value::text("two"), Value::Number(3.0), Value::text("four"))
|
||||
);
|
||||
assert_eq!(code(&s, "B1").unwrap().code, "xl('A1') * 2");
|
||||
assert!(code(&s, "E1").unwrap().object);
|
||||
assert_eq!(code(&s, "F1").unwrap().code, long, "long code with quotes and line breaks survives");
|
||||
assert_eq!(v(&s, "G1"), Value::Error(CellError::Value));
|
||||
// A blocker typed into the spill area gives #SPILL!; clearing it spills again, without
|
||||
// running anything.
|
||||
s.execute("cell.set", json!({"cell": "D2", "input": "x"})).unwrap();
|
||||
assert_eq!(v(&s, "C1"), Value::Error(CellError::Spill));
|
||||
s.execute("edit.clearContents", json!({"range": "D2"})).unwrap();
|
||||
assert_eq!(v(&s, "D2"), Value::text("four"));
|
||||
// Saved again, the file holds the same formulas and scripts.
|
||||
let again = save(&mut s, "xlsx");
|
||||
assert!(sheet_xml(&again).contains("<f>_xlfn._xlws.PY(0,0)</f>"));
|
||||
assert_eq!(part(&again, "xl/python.xml"), python);
|
||||
// A workbook without Python cells has no Python part.
|
||||
let mut plain = super::s();
|
||||
assert!(!save(&mut plain, "xlsx").windows(10).any(|w| w == b"python.xml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excel_authored_python_cells_are_read_as_python() {
|
||||
// A workbook as [MS-XLSX] 2.3.10 describes it: `_xlfn._xlws.PY(index,0|1,params…)` naming a
|
||||
// script of the Python part, whose code names the parameters as `%Px%`, with a spilled result
|
||||
// cached.
|
||||
let data = "<sheetData><row r=\"1\"><c r=\"A1\" cm=\"1\"><f t=\"array\" ref=\"A1:A2\">_xlfn._xlws.PY(0,0)</f><v>1</v></c>\
|
||||
<c r=\"B1\" t=\"str\"><f>_xlfn._xlws.PY(1,1,C1:C3)</f><v>DataFrame</v></c></row>\
|
||||
<row r=\"2\"><c r=\"A2\"><v>2</v></c></row></sheetData>";
|
||||
let bytes = excel_shaped(data, Some(&["[1, 2]", "df = xl(\"%P2%\", headers=True)\ndf.describe()"]));
|
||||
let mut s = s();
|
||||
open(&mut s, "excel.xlsx", &bytes);
|
||||
let a1 = code(&s, "A1").expect("an Excel Python cell is a Python cell");
|
||||
assert_eq!(a1.code, "[1, 2]");
|
||||
assert!(!a1.object);
|
||||
assert_eq!((v(&s, "A1"), v(&s, "A2")), (Value::Number(1.0), Value::Number(2.0)), "its spilled values are kept");
|
||||
let cell = s.doc().unwrap().wb.active().unwrap().cell(CellRef::new(0, 0)).unwrap().clone();
|
||||
assert!(cell.formula.is_none());
|
||||
let b1 = code(&s, "B1").unwrap();
|
||||
assert_eq!(b1.code, "df = xl(\"C1:C3\", headers=True)\ndf.describe()", "a parameter is put back as its reference");
|
||||
assert!(b1.object);
|
||||
assert_eq!(v(&s, "B1"), Value::text("DataFrame"));
|
||||
// Saved by GridCraft, the code goes back into a Python part, the reference as text.
|
||||
let again = save(&mut s, "xlsx");
|
||||
assert!(part(&again, "xl/python.xml").contains("<code>df = xl(\"C1:C3\", headers=True)\ndf.describe()</code>"));
|
||||
// The string-literal form (other applications, files GridCraft wrote before) still reads.
|
||||
let literal = "<sheetData><row r=\"1\"><c r=\"A1\"><f>_xlfn._xlws.PY(\"1 + 1\",1)</f><v>2</v></c></row></sheetData>";
|
||||
open(&mut s, "literal.xlsx", &excel_shaped(literal, None));
|
||||
assert_eq!(code(&s, "A1").map(|p| (p.code, p.object)), Some(("1 + 1".to_string(), true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_py_formula_naming_a_missing_script_keeps_only_its_value() {
|
||||
let data = "<sheetData><row r=\"1\"><c r=\"A1\"><f>_xlfn._xlws.PY(0,0)</f><v>5</v></c><c r=\"B1\"><f>_xlfn._xlws.PY(3,0)</f><v>6</v></c></row></sheetData>";
|
||||
for (name, bytes) in [("one.xlsx", excel_shaped(data, Some(&["5"]))), ("none.xlsx", excel_shaped(data, None))] {
|
||||
let (wb, warnings) = crate::io::open_bytes(name, &bytes).unwrap();
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
let b1 = sh.cell(CellRef::parse("B1").unwrap()).unwrap();
|
||||
assert!(b1.py.is_none() && b1.formula.is_none(), "{name}: no code to keep, and no formula #NAME? would replace");
|
||||
assert_eq!(b1.value, Value::Number(6.0), "{name}");
|
||||
assert!(warnings.iter().any(|w| w.contains("Python")), "{name}: {warnings:?}");
|
||||
}
|
||||
let (wb, _) = crate::io::open_bytes("one.xlsx", &excel_shaped(data, Some(&["5"]))).unwrap();
|
||||
assert_eq!(wb.sheet(0).unwrap().cell(CellRef::parse("A1").unwrap()).unwrap().py.as_ref().map(|p| p.code.as_str()), Some("5"));
|
||||
}
|
||||
|
||||
/// `bytes` with the part `name` stored uncompressed and one byte of its data changed, so the
|
||||
/// archive is intact but reading the part fails its checksum.
|
||||
fn with_damaged_part(bytes: &[u8], name: &str, marker: &str) -> Vec<u8> {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).unwrap();
|
||||
let entry = f.name().to_string();
|
||||
let mut data = Vec::new();
|
||||
f.read_to_end(&mut data).unwrap();
|
||||
let options = zip::write::SimpleFileOptions::default();
|
||||
let options = if entry == name { options.compression_method(zip::CompressionMethod::Stored) } else { options };
|
||||
out.start_file(entry, options).unwrap();
|
||||
out.write_all(&data).unwrap();
|
||||
}
|
||||
let mut zipped = out.finish().unwrap().into_inner();
|
||||
let at = zipped.windows(marker.len()).position(|w| w == marker.as_bytes()).expect("the marker is stored as it is");
|
||||
zipped[at + 1] ^= 0x20;
|
||||
zipped
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_python_part_is_a_warning_and_the_workbook_opens_with_its_values() {
|
||||
let data = "<sheetData><row r=\"1\"><c r=\"A1\"><f>_xlfn._xlws.PY(0,0)</f><v>5</v></c><c r=\"B1\"><v>9</v></c></row></sheetData>";
|
||||
let good = excel_shaped(data, Some(&["5"]));
|
||||
// The part's data fails its checksum (a damaged archive entry).
|
||||
let damaged = with_damaged_part(&good, "xl/python/python1.xml", "<pythonScripts>");
|
||||
// The part is not well-formed XML, before its first script.
|
||||
let broken = |at: &'static str, with: &'static str| {
|
||||
with_parts(&good, move |name, text| if name == "xl/python/python1.xml" { text.replace(at, with) } else { text.to_string() }, &[])
|
||||
};
|
||||
let malformed = broken("<pythonScripts>", "<pythonScripts><!-- never closed");
|
||||
for (name, bytes) in [("damaged.xlsx", damaged), ("malformed.xlsx", malformed)] {
|
||||
let (wb, warnings) = crate::io::open_bytes(name, &bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
let a1 = sh.cell(CellRef::parse("A1").unwrap()).unwrap();
|
||||
assert_eq!(a1.value, Value::Number(5.0), "{name}: the cached value is kept");
|
||||
assert!(a1.py.is_none() && a1.formula.is_none(), "{name}: as for a missing script, only the value");
|
||||
assert_eq!(sh.value(CellRef::parse("B1").unwrap()), Value::Number(9.0), "{name}: the rest of the sheet is read");
|
||||
let part = warnings.iter().filter(|w| w.contains("Python part is damaged")).count();
|
||||
assert_eq!(part, 1, "{name}: {warnings:?}");
|
||||
}
|
||||
// Damaged after its first script, the part keeps what it read before the damage.
|
||||
let (wb, warnings) = crate::io::open_bytes("late.xlsx", &broken("</pythonScripts>", "<!-- never closed")).unwrap();
|
||||
let a1 = wb.sheet(0).unwrap().cell(CellRef::parse("A1").unwrap()).unwrap().clone();
|
||||
assert_eq!(a1.py.as_ref().map(|p| p.code.as_str()), Some("5"));
|
||||
assert_eq!(warnings.iter().filter(|w| w.contains("Python part is damaged")).count(), 1, "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_python_cell_without_its_code_keeps_its_whole_cached_result_as_values() {
|
||||
// A1's cached result spills over A1:A3; C1 is an ordinary dynamic array with a stale cache.
|
||||
let data = "<sheetData><row r=\"1\"><c r=\"A1\" cm=\"1\"><f t=\"array\" ref=\"A1:A3\">_xlfn._xlws.PY(0,0)</f><v>1</v></c>\
|
||||
<c r=\"C1\" cm=\"1\"><f t=\"array\" ref=\"C1:C3\">SEQUENCE(3)</f><v>1</v></c></row>\
|
||||
<row r=\"2\"><c r=\"A2\"><v>2</v></c><c r=\"C2\"><v>9</v></c></row>\
|
||||
<row r=\"3\"><c r=\"A3\"><v>3</v></c><c r=\"C3\"><v>9</v></c></row></sheetData>";
|
||||
let missing = excel_shaped(data, None);
|
||||
let damaged = with_damaged_part(&excel_shaped(data, Some(&["[1, 2, 3]"])), "xl/python/python1.xml", "<pythonScripts>");
|
||||
for (name, bytes) in [("missing.xlsx", missing), ("damaged.xlsx", damaged)] {
|
||||
let mut s = s();
|
||||
open(&mut s, name, &bytes);
|
||||
assert_eq!((v(&s, "A1"), v(&s, "A2"), v(&s, "A3")), (Value::Number(1.0), Value::Number(2.0), Value::Number(3.0)), "{name}");
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
for a in ["A1", "A2", "A3"] {
|
||||
let cell = sh.cell(CellRef::parse(a).unwrap()).unwrap_or_else(|| panic!("{name}: {a} is stored"));
|
||||
assert!(cell.py.is_none() && cell.formula.is_none(), "{name}: {a} is a plain value");
|
||||
}
|
||||
assert!(!sh.spill_ranges.contains_key(&CellRef::new(0, 0)), "{name}: no live spill");
|
||||
// The ordinary formula fills its spill again, over the stale cached values.
|
||||
assert_eq!((v(&s, "C2"), v(&s, "C3")), (Value::Number(2.0), Value::Number(3.0)), "{name}");
|
||||
assert!(sh.cell(CellRef::parse("C2").unwrap()).is_none(), "{name}: C2 is the formula's spill, not a cell");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_python_spill_whose_blank_cells_have_no_records_is_still_a_spill() {
|
||||
// A1's result spills over A1:A3, and the file holds no records for the blank A2 and A3.
|
||||
let data = "<sheetData><row r=\"1\"><c r=\"A1\" cm=\"1\"><f t=\"array\" ref=\"A1:A3\">_xlfn._xlws.PY(0,0)</f><v>1</v></c></row></sheetData>";
|
||||
let mut s = s();
|
||||
open(&mut s, "sparse.xlsx", &excel_shaped(data, Some(&["[1, None, None]"])));
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
let a1 = CellRef::new(0, 0);
|
||||
assert_eq!(sh.spill_ranges.get(&a1), Some(&RangeRef::parse("A1:A3").unwrap()), "the range is recorded at its anchor");
|
||||
assert_eq!(sh.spill_anchor(CellRef::parse("A3").unwrap()), Some(a1));
|
||||
let kept = sh.cell(a1).and_then(|c| c.py.as_ref()).and_then(|p| p.output.as_ref()).map(|o| o.value.clone());
|
||||
assert_eq!(kept, Some(array(3, 1, vec![1.0.into(), Value::Empty, Value::Empty])), "the result is rebuilt over the whole range");
|
||||
// A formula entered in the spill takes the area, as with any spill.
|
||||
s.execute("cell.set", json!({"cell": "A3", "input": "7"})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Error(CellError::Spill));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_python_spill_range_larger_than_a_result_keeps_its_values_as_plain_values() {
|
||||
// C1:D600000 is 1,200,000 cells, more than a result may have but within what the reader
|
||||
// tracks; its cached values are kept as the file shows them, and nothing spills.
|
||||
let (mut s, _) = build();
|
||||
let bytes = save(&mut s, "xlsx");
|
||||
let crafted = with_sheet_xml(&bytes, |xml| xml.replace("ref=\"C1:D2\"", "ref=\"C1:D600000\""));
|
||||
assert_ne!(crafted, bytes);
|
||||
open(&mut s, "crafted.xlsx", &crafted);
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert!(!sh.spill_ranges.contains_key(&CellRef::new(0, 2)), "{:?}", sh.spill_ranges);
|
||||
assert_eq!(
|
||||
(v(&s, "C1"), v(&s, "D1"), v(&s, "C2"), v(&s, "D2")),
|
||||
(Value::Number(1.0), Value::text("two"), Value::Number(3.0), Value::text("four"))
|
||||
);
|
||||
let d2 = sh.cell(CellRef::parse("D2").unwrap()).expect("D2 is stored");
|
||||
assert!(d2.py.is_none() && d2.formula.is_none(), "a plain value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_spills_read_past_the_scan_budget_keep_their_values_as_plain_values() {
|
||||
// A1's result spills over A1:A5000. Four thousand ordinary dynamic arrays on row 1 make each
|
||||
// later plain cell's range lookup cost 4,001 checks, so the reader's scan budget runs out
|
||||
// part way down column A, and the rest of the spill is read as plain cells.
|
||||
let arrays: String = (1..=4_000u32)
|
||||
.map(|c| {
|
||||
let at = CellRef::new(0, c).a1();
|
||||
let below = CellRef::new(1, c).a1();
|
||||
format!("<c r=\"{at}\" cm=\"1\"><f t=\"array\" ref=\"{at}:{below}\">SEQUENCE(2)</f><v>1</v></c>")
|
||||
})
|
||||
.collect();
|
||||
let rows: String = (2..=5_000u32).map(|r| format!("<row r=\"{r}\"><c r=\"A{r}\"><v>{r}</v></c></row>")).collect();
|
||||
let data = format!(
|
||||
"<sheetData><row r=\"1\"><c r=\"A1\" cm=\"1\"><f t=\"array\" ref=\"A1:A5000\">_xlfn._xlws.PY(0,0)</f><v>1</v></c>{arrays}</row>{rows}</sheetData>"
|
||||
);
|
||||
let mut s = s();
|
||||
open(&mut s, "budget.xlsx", &excel_shaped(&data, Some(&["list(range(1, 5001))"])));
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert!(!sh.spill_ranges.contains_key(&CellRef::new(0, 0)), "no live spill");
|
||||
assert!(code(&s, "A1").is_some_and(|p| p.output.is_none()), "no result is rebuilt");
|
||||
assert_eq!((v(&s, "A2"), v(&s, "A5000")), (Value::Number(2.0), Value::Number(5000.0)), "the values stay");
|
||||
s.execute("formulas.calculateNow", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Number(1.0), "the cell keeps its value, not #SPILL!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_the_data_python_code_reads_drops_its_kept_result() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "2"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A3", "input": "3"})).unwrap();
|
||||
py(&mut s, "C5", "xl(\"A1:A2\") * 2");
|
||||
cache(&mut s, "C5", array(2, 1, vec![2.0.into(), 4.0.into()]), "list");
|
||||
py(&mut s, "E5", "xl(\"A1:A3\")");
|
||||
cache(&mut s, "E5", Value::Number(3.0), "int");
|
||||
s.execute("formulas.calculateNow", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "C6"), Value::Number(4.0));
|
||||
// A move keeps the result: the reference still names the same data.
|
||||
s.execute("home.insertRows", json!({"rows": "1"})).unwrap();
|
||||
assert!(code(&s, "C6").is_some_and(|p| p.output.is_some() && !p.code.contains("A1:A2")), "{:?}", code(&s, "C6"));
|
||||
assert_eq!(v(&s, "C7"), Value::Number(4.0));
|
||||
assert!(code(&s, "E6").is_some_and(|p| p.output.is_some()));
|
||||
// Deleting the rows C6 reads leaves `#REF!`, and deleting part of E6's range shrinks it: in
|
||||
// both, the kept result no longer belongs to the code.
|
||||
s.execute("home.deleteRows", json!({"rows": "2:3"})).unwrap();
|
||||
let c4 = code(&s, "C4").expect("the Python cell moved up to C4");
|
||||
assert!(c4.code.contains("#REF!") && c4.output.is_none(), "{c4:?}");
|
||||
assert_eq!(v(&s, "C4"), Value::Error(CellError::Blocked));
|
||||
assert_eq!(v(&s, "C5"), Value::Empty, "its spill is gone");
|
||||
let e4 = code(&s, "E4").expect("the Python cell moved up to E4");
|
||||
assert!(!e4.code.contains("#REF!") && e4.output.is_none(), "{e4:?}");
|
||||
assert_eq!(v(&s, "E4"), Value::Error(CellError::Blocked));
|
||||
// Rows inserted inside a range grow it: also new data.
|
||||
py(&mut s, "G1", "xl(\"A1:A2\")");
|
||||
cache(&mut s, "G1", Value::Number(2.0), "int");
|
||||
s.execute("home.insertRows", json!({"rows": "2"})).unwrap();
|
||||
assert!(code(&s, "G1").is_some_and(|p| p.output.is_none()), "{:?}", code(&s, "G1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_script_is_measured_after_its_escapes_are_decoded() {
|
||||
let max = gridcraft_model::python::MAX_FORMULA_CHARS;
|
||||
// 2,000 tabs written as `_x0009_`: 14,000 characters in the file, 2,004 as code, which fits.
|
||||
let escaped = format!("x = 1{}end", "_x0009_".repeat(2_000));
|
||||
assert!(escaped.chars().count() > max);
|
||||
// Code longer than a cell may hold once decoded is still refused.
|
||||
let long = format!("{}{}", "_x0009_".repeat(10), "y".repeat(max));
|
||||
let data = "<sheetData><row r=\"1\"><c r=\"A1\"><f>_xlfn._xlws.PY(0,0)</f><v>1</v></c><c r=\"B1\"><f>_xlfn._xlws.PY(1,0)</f><v>2</v></c></row></sheetData>";
|
||||
let (wb, warnings) = crate::io::open_bytes("escapes.xlsx", &excel_shaped(data, Some(&[&escaped, &long]))).unwrap();
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
let a1 = sh.cell(CellRef::parse("A1").unwrap()).unwrap().py.as_ref().map(|p| p.code.clone());
|
||||
assert_eq!(a1, Some(format!("x = 1{}end", "\t".repeat(2_000))), "read in full");
|
||||
let b1 = sh.cell(CellRef::parse("B1").unwrap()).unwrap();
|
||||
assert!(b1.py.is_none(), "too long once decoded: only the value");
|
||||
assert_eq!(b1.value, Value::Number(2.0));
|
||||
assert!(warnings.iter().any(|w| w.contains("B1")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_cells_naming_one_long_script_take_from_one_budget() {
|
||||
// 20,000 cells name one script of 8,000 characters: 160 MB of code if each took a copy.
|
||||
let long = "x".repeat(8_000);
|
||||
let mut data = String::from("<sheetData>");
|
||||
for r in 1..=20_000 {
|
||||
data.push_str(&format!("<row r=\"{r}\"><c r=\"A{r}\"><f>_xlfn._xlws.PY(0,0)</f><v>1</v></c></row>"));
|
||||
}
|
||||
data.push_str("</sheetData>");
|
||||
let (wb, warnings) = crate::io::open_bytes("many.xlsx", &excel_shaped(&data, Some(&[&long]))).unwrap();
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
let with_code = sh.cells.iter().filter(|(_, c)| c.py.is_some()).count();
|
||||
assert_eq!(with_code, 64 * 1024 * 1024 / 8_000);
|
||||
assert!(warnings.iter().any(|w| w.contains("Python")), "{warnings:?}");
|
||||
assert_eq!(sh.value(CellRef::parse("A20000").unwrap()), Value::Number(1.0), "the others keep their values");
|
||||
}
|
||||
|
||||
/// Plants a cached spill over `range` (anchored at its start) with `value` at `at`, as a file
|
||||
/// read leaves it.
|
||||
fn cached_spill(sh: &mut gridcraft_model::Sheet, range: RangeRef, spilled: &[(CellRef, Value)]) {
|
||||
sh.spill_ranges.insert(range.start, range);
|
||||
for (c, v) in spilled {
|
||||
sh.spill.insert(*c, v.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// The value stored in the cell itself (not a spilled one), with whether it holds code or a
|
||||
/// formula.
|
||||
fn stored(sh: &gridcraft_model::Sheet, c: CellRef) -> (Value, bool) {
|
||||
sh.cell(c).map(|x| (x.value.clone(), x.py.is_some() || x.formula.is_some())).unwrap_or((Value::Empty, false))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_result_cut_by_the_budget_keeps_its_cached_spill_as_values() {
|
||||
// Five Python cells each with a cached 1,000 x 1,000 spill: the budget rebuilds four results;
|
||||
// the fifth cell keeps only its own value, and the values it spilled stay as plain values.
|
||||
let mut wb = Workbook::new();
|
||||
let bold = wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| st.font.bold = true);
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
for i in 0..5u32 {
|
||||
let col = i * 1_000;
|
||||
let anchor = CellRef::new(0, col);
|
||||
sh.set_cell(anchor, Cell { py: Some(Arc::new(PyCode::new("x"))), ..Cell::value(Value::Number(1.0)) });
|
||||
// A cell with its own content, and a formatted empty one, inside the spill.
|
||||
sh.set_cell(CellRef::new(2, col), Cell::value(Value::text("own")));
|
||||
sh.set_cell(CellRef::new(3, col), Cell::styled(Value::Empty, bold));
|
||||
let spilled = [
|
||||
(CellRef::new(1, col), Value::Number(7.0)),
|
||||
(CellRef::new(2, col), Value::Number(9.0)),
|
||||
(CellRef::new(3, col), Value::Number(6.0)),
|
||||
(CellRef::new(999, col + 999), Value::Number(8.0)),
|
||||
];
|
||||
cached_spill(sh, RangeRef::new(anchor, CellRef::new(999, col + 999)), &spilled);
|
||||
}
|
||||
let warnings = crate::io::adopt_python(&mut wb);
|
||||
assert!(warnings.iter().any(|w| w.contains("Python")), "{warnings:?}");
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
let mut cut = 0;
|
||||
for i in 0..5u32 {
|
||||
let col = i * 1_000;
|
||||
let anchor = CellRef::new(0, col);
|
||||
let py = sh.cell(anchor).and_then(|c| c.py.clone()).expect("the code is kept");
|
||||
let shown =
|
||||
[sh.value(CellRef::new(1, col)), sh.value(CellRef::new(2, col)), sh.value(CellRef::new(3, col)), sh.value(CellRef::new(999, col + 999))];
|
||||
assert_eq!(shown, [Value::Number(7.0), Value::text("own"), Value::Number(6.0), Value::Number(8.0)], "every result shows the same values");
|
||||
if py.output.is_some() {
|
||||
assert!(sh.spill_ranges.contains_key(&anchor), "a result within the budget keeps its spill");
|
||||
continue;
|
||||
}
|
||||
cut += 1;
|
||||
assert!(!sh.spill_ranges.contains_key(&anchor), "no live spill");
|
||||
assert!(sh.spill.range(anchor..=CellRef::new(999, col + 999)).all(|(c, _)| c.col < col || c.col > col + 999), "no spilled values left");
|
||||
assert_eq!(stored(sh, anchor), (Value::Number(1.0), true), "the anchor keeps its value and its code");
|
||||
assert_eq!(stored(sh, CellRef::new(1, col)), (Value::Number(7.0), false), "a spilled value is a plain value now");
|
||||
assert_eq!(stored(sh, CellRef::new(999, col + 999)), (Value::Number(8.0), false));
|
||||
assert_eq!(stored(sh, CellRef::new(2, col)), (Value::text("own"), false), "content is never overwritten");
|
||||
assert_eq!(sh.style_id(CellRef::new(3, col)), bold, "nor a format");
|
||||
}
|
||||
assert_eq!(cut, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_too_long_for_a_cell_keeps_its_whole_cached_result_as_values() {
|
||||
let long = PyCode::new(&"x".repeat(gridcraft_model::python::MAX_FORMULA_CHARS));
|
||||
assert!(!long.fits());
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
// A1 spills over A1:B2; D1's own value is empty.
|
||||
sh.set_cell(CellRef::new(0, 0), Cell { py: Some(Arc::new(long.clone())), ..Cell::value(Value::Number(1.0)) });
|
||||
sh.set_cell(CellRef::new(1, 1), Cell::value(Value::text("own")));
|
||||
let spilled = [(CellRef::new(0, 1), Value::Number(2.0)), (CellRef::new(1, 0), Value::Number(3.0)), (CellRef::new(1, 1), Value::Number(4.0))];
|
||||
cached_spill(sh, RangeRef::new(CellRef::new(0, 0), CellRef::new(1, 1)), &spilled);
|
||||
sh.set_cell(CellRef::new(0, 3), Cell { py: Some(Arc::new(long)), ..Cell::default() });
|
||||
cached_spill(sh, RangeRef::new(CellRef::new(0, 3), CellRef::new(1, 3)), &[(CellRef::new(1, 3), Value::Number(5.0))]);
|
||||
let warnings = crate::io::enforce_python_limits(&mut wb);
|
||||
assert!(warnings.iter().any(|w| w.contains("A1")), "{warnings:?}");
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
assert!(sh.spill_ranges.is_empty() && sh.spill.is_empty(), "no live spill: {:?}", sh.spill_ranges);
|
||||
assert_eq!(stored(sh, CellRef::new(0, 0)), (Value::Number(1.0), false), "the anchor keeps only its value");
|
||||
assert_eq!(stored(sh, CellRef::new(0, 1)), (Value::Number(2.0), false));
|
||||
assert_eq!(stored(sh, CellRef::new(1, 0)), (Value::Number(3.0), false));
|
||||
assert_eq!(stored(sh, CellRef::new(1, 1)), (Value::text("own"), false), "content is never overwritten");
|
||||
assert_eq!(stored(sh, CellRef::new(0, 3)), (Value::Empty, false), "an empty anchor stays empty, without code");
|
||||
assert_eq!(stored(sh, CellRef::new(1, 3)), (Value::Number(5.0), false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_json_keeps_python_cells_and_their_results() {
|
||||
let (mut s, long) = build();
|
||||
let bytes = save(&mut s, "json");
|
||||
open(&mut s, "book.json", &bytes);
|
||||
assert_eq!(s.doc().unwrap().trust, Trust::FromFile);
|
||||
assert_eq!(v(&s, "B1"), Value::Number(42.0));
|
||||
assert_eq!(v(&s, "D2"), Value::text("four"));
|
||||
assert_eq!(v(&s, "G1"), Value::Error(CellError::Python), "the native format keeps the exact error");
|
||||
assert_eq!(code(&s, "F1").unwrap().code, long);
|
||||
let e1 = code(&s, "E1").unwrap();
|
||||
assert!(e1.object);
|
||||
assert_eq!(e1.output.unwrap().type_name, "list");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spill_range_claiming_the_whole_sheet_is_dropped_on_open() {
|
||||
let (mut s, _) = build();
|
||||
let bytes = save(&mut s, "xlsx");
|
||||
let evil = with_sheet_xml(&bytes, |xml| xml.replace("ref=\"C1:D2\"", "ref=\"C1:XFD1048576\""));
|
||||
assert_ne!(evil, bytes);
|
||||
// Walking the claimed range would take hours; the test finishing is the check.
|
||||
open(&mut s, "evil.xlsx", &evil);
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert!(sh.spill_ranges.values().all(|r| r.count() <= gridcraft_model::python::MAX_RESULT_CELLS as u64), "{:?}", sh.spill_ranges);
|
||||
// Edits afterwards don't walk the claimed range either.
|
||||
s.execute("cell.set", json!({"cell": "H9", "input": "1"})).unwrap();
|
||||
s.execute("formulas.calculateNow", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "C1"), Value::Number(1.0), "the anchor keeps its cached value");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuilt_results_have_a_budget_across_the_workbook() {
|
||||
// Five Python cells each claiming a 1,000 x 1,000 spill: more than the budget in all.
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
for i in 0..5u32 {
|
||||
let anchor = CellRef::new(0, i * 1_000);
|
||||
sh.set_cell(anchor, Cell { py: Some(Arc::new(PyCode::new("x"))), ..Cell::value(Value::Number(1.0)) });
|
||||
sh.spill_ranges.insert(anchor, RangeRef::new(anchor, CellRef::new(999, i * 1_000 + 999)));
|
||||
}
|
||||
let warnings = crate::io::adopt_python(&mut wb);
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
let rebuilt = sh.cells.iter().filter(|(_, c)| c.py.as_ref().is_some_and(|p| p.output.is_some())).count();
|
||||
assert_eq!(rebuilt as u64, crate::io::MAX_ADOPTED_RESULT_CELLS / 1_000_000);
|
||||
assert!(warnings.iter().any(|w| w.contains("Python")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_python_content_in_files_is_capped() {
|
||||
let (mut s, _) = build();
|
||||
let bytes = save(&mut s, "json");
|
||||
// Make the Python cells hostile: code over the limit, and a result whose shape claims far
|
||||
// more cells than it holds (the array fields are public, so a crafted file can say anything).
|
||||
let mut wb: Workbook = serde_json::from_slice(&bytes).unwrap();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
let b1 = sh.cells.get_mut(CellRef::parse("B1").unwrap()).unwrap();
|
||||
Arc::make_mut(b1.py.as_mut().unwrap()).code = "y".repeat(9_000);
|
||||
let c1 = sh.cells.get_mut(CellRef::parse("C1").unwrap()).unwrap();
|
||||
let out = Arc::make_mut(c1.py.as_mut().unwrap()).output.as_mut().unwrap();
|
||||
out.value = Value::Array(Arc::new(Array { rows: 100_000, cols: 100_000, data: vec![Value::Number(1.0)] }));
|
||||
let bytes = serde_json::to_vec(&wb).unwrap();
|
||||
let (wb, warnings) = crate::io::open_bytes("evil.json", &bytes).unwrap();
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
let b1 = sh.cell(CellRef::parse("B1").unwrap()).unwrap();
|
||||
assert!(b1.py.is_none(), "code over the formula limit is dropped");
|
||||
assert_eq!(b1.value, Value::Number(42.0), "the cached value stays");
|
||||
assert!(warnings.iter().any(|w| w.contains("Python cell")), "{warnings:?}");
|
||||
let c1 = sh.cell(CellRef::parse("C1").unwrap()).unwrap();
|
||||
assert!(c1.py.as_ref().unwrap().output.is_none(), "a malformed result is dropped");
|
||||
open(&mut s, "evil.json", &bytes);
|
||||
s.execute("formulas.calculateNow", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "C1"), Value::Number(1.0));
|
||||
|
||||
// PY formulas from other formats become Python cells; non-literal PY calls stay formulas.
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
sh.set_cell(CellRef::new(0, 0), Cell::formula(gridcraft_model::Formula::new("=PY(\"1+\"&\"1\",1)")));
|
||||
sh.set_cell(CellRef::new(1, 0), Cell::formula(gridcraft_model::Formula::new("=PY(A1,0)")));
|
||||
sh.set_cell(CellRef::new(2, 0), Cell::formula(gridcraft_model::Formula::new("=_xlfn._xlws.PY(\"2\",0)")));
|
||||
crate::io::adopt_python(&mut wb);
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
let a1 = sh.cell(CellRef::new(0, 0)).unwrap();
|
||||
assert_eq!(a1.py.as_ref().map(|p| (p.code.as_str(), p.object)), Some(("1+1", true)));
|
||||
assert!(a1.formula.is_none());
|
||||
assert!(sh.cell(CellRef::new(1, 0)).unwrap().py.is_none());
|
||||
assert_eq!(sh.cell(CellRef::new(2, 0)).unwrap().py.as_ref().map(|p| p.code.as_str()), Some("2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_result_never_spills() {
|
||||
let mut s = s();
|
||||
py(&mut s, "A1", "x");
|
||||
let bad = Array { rows: 4_000_000_000, cols: 4_000_000_000, data: vec![] };
|
||||
cache(&mut s, "A1", Value::Array(Arc::new(bad)), "list");
|
||||
s.execute("formulas.calculateNow", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "A1"), Value::Error(CellError::Spill));
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().spill.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn py_formulas_over_the_size_limit_keep_only_their_value() {
|
||||
let lits = |n: usize| {
|
||||
let mut v = vec![format!("\"{}\"", "x".repeat(255)); n / 255];
|
||||
if !n.is_multiple_of(255) {
|
||||
v.push(format!("\"{}\"", "x".repeat(n % 255)));
|
||||
}
|
||||
v.join("&")
|
||||
};
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
let cell = |text: String| Cell { value: Value::text("cached"), ..Cell::formula(gridcraft_model::Formula::new(&text)) };
|
||||
sh.set_cell(CellRef::new(0, 0), cell(format!("_xlfn._xlws.PY({},0)", lits(8_080))));
|
||||
sh.set_cell(CellRef::new(1, 0), cell(format!("_xlfn._xlws.PY({},0)", lits(8_079))));
|
||||
let xlsx = gridcraft_xlsx::write_xlsx(&wb).unwrap();
|
||||
let (wb, warnings) = crate::io::open_bytes("long.xlsx", &xlsx).unwrap();
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
let over = sh.cell(CellRef::new(0, 0)).unwrap();
|
||||
assert!(over.py.is_none() && over.formula.is_none(), "8,080 characters of code is over the limit");
|
||||
assert_eq!(over.value, Value::text("cached"));
|
||||
let within = sh.cell(CellRef::new(1, 0)).unwrap();
|
||||
assert_eq!(within.py.as_ref().map(|p| p.code.chars().count()), Some(8_079));
|
||||
assert!(warnings.iter().any(|w| w.contains("A1")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dropped_python_result_keeps_its_spill_as_values() {
|
||||
// A1's code is over the limit and B1's result is malformed; each had spilled down to row 3,
|
||||
// and C1's well-formed result keeps its spill.
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
let out = |value: Value| Some(PyOutput { value, type_name: "list".into(), preview: String::new(), convertible: true });
|
||||
let col = array(3, 1, vec![Value::Number(7.0), Value::Number(8.0), Value::Number(9.0)]);
|
||||
let bad = Value::Array(Arc::new(Array { rows: 3, cols: 1, data: vec![Value::Number(7.0)] }));
|
||||
for (at, code, value) in [("A1", "y".repeat(9_000), col.clone()), ("B1", "b".into(), bad), ("C1", "c".into(), col)] {
|
||||
let at = CellRef::parse(at).unwrap();
|
||||
let py = PyCode { output: out(value), ..PyCode::new(&code) };
|
||||
sh.set_cell(at, Cell { value: Value::Number(7.0), formula: None, style: Default::default(), py: Some(Arc::new(py)) });
|
||||
let r = RangeRef::new(at, CellRef::new(2, at.col));
|
||||
sh.spill_ranges.insert(at, r);
|
||||
for c in r.iter().skip(1) {
|
||||
sh.spill.insert(c, Value::Number(8.0));
|
||||
}
|
||||
}
|
||||
let warnings = crate::io::enforce_python_limits(&mut wb);
|
||||
assert!(warnings.iter().any(|w| w.contains("A1")), "{warnings:?}");
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
for (anchor, below) in [("A1", "A2"), ("B1", "B3")] {
|
||||
assert!(!sh.spill_ranges.contains_key(&CellRef::parse(anchor).unwrap()), "{anchor}'s spill range stays");
|
||||
let cell = sh.cell(CellRef::parse(below).unwrap()).unwrap_or_else(|| panic!("{below} is a plain value"));
|
||||
assert_eq!((&cell.value, cell.py.is_none() && cell.formula.is_none()), (&Value::Number(8.0), true), "{below} keeps {anchor}'s value");
|
||||
assert_eq!(sh.value(CellRef::parse(anchor).unwrap()), Value::Number(7.0), "{anchor} keeps its value");
|
||||
}
|
||||
assert_eq!(sh.value(CellRef::parse("C2").unwrap()), Value::Number(8.0), "a sound result keeps its spill");
|
||||
// A save writes the kept values as plain values, and the sound spill as a spill.
|
||||
let xml = sheet_xml(&gridcraft_xlsx::write_xlsx(&wb).unwrap());
|
||||
assert!(xml.contains("<c r=\"A2\"><v>8</v></c>") && xml.contains("<c r=\"B3\"><v>8</v></c>"), "{xml}");
|
||||
assert!(xml.contains("r=\"C2\""), "{xml}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_formula_entered_in_a_python_spill_takes_the_area_as_a_formula_would() {
|
||||
// A1's cached array spills over A1:A3; =SEQUENCE(2) entered in A2 is not blocked by that old
|
||||
// spill (the Python cell is laid out again in the same pass), so it spills and A1 is blocked.
|
||||
let mut s = s();
|
||||
py(&mut s, "A1", "[1, 2, 3]");
|
||||
cache(&mut s, "A1", array(3, 1, vec![10.0.into(), 20.0.into(), 30.0.into()]), "list");
|
||||
s.execute("formulas.calculateNow", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "A3"), Value::Number(30.0));
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "=SEQUENCE(2)"})).unwrap();
|
||||
assert_eq!((v(&s, "A2"), v(&s, "A3")), (Value::Number(1.0), Value::Number(2.0)));
|
||||
assert_eq!(v(&s, "A1"), Value::Error(CellError::Spill));
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
let mut fresh = (**wb).clone();
|
||||
gridcraft_calc::Calc::new().recalc_all(&mut fresh);
|
||||
for c in ["A1", "A2", "A3"] {
|
||||
let c = CellRef::parse(c).unwrap();
|
||||
assert_eq!(wb.active().unwrap().value(c), fresh.active().unwrap().value(c), "{c}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_return_type_drops_the_kept_result() {
|
||||
// Same code, the other return type: the result belongs to the old type, so it goes.
|
||||
let mut s = super::python_cells::spill_with_a_reader();
|
||||
s.execute("python.set", json!({"cell": "A1", "code": "[5, 6]", "output": "object"})).unwrap();
|
||||
let a1 = code(&s, "A1").unwrap();
|
||||
assert!(a1.object && a1.output.is_none(), "no result of the other type is kept");
|
||||
assert_eq!(v(&s, "A1"), Value::Error(CellError::Blocked));
|
||||
assert_eq!(v(&s, "A2"), Value::Empty, "no spill");
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().spill_ranges.is_empty());
|
||||
assert_eq!(v(&s, "B1"), Value::Number(0.0), "the reader of the old spill is recalculated");
|
||||
let xml = sheet_xml(&save(&mut s, "xlsx"));
|
||||
assert!(xml.contains("<f>_xlfn._xlws.PY(0,1)</f><v>#VALUE!</v>") && !xml.contains("r=\"A2\""), "no old cached value is written: {xml}");
|
||||
// Undo brings the result back.
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let a1 = code(&s, "A1").unwrap();
|
||||
assert!(!a1.object && a1.output.is_some());
|
||||
assert_eq!((v(&s, "A1"), v(&s, "A2"), v(&s, "B1")), (Value::Number(5.0), Value::Number(6.0), Value::Number(6.0)));
|
||||
// Same code, same return type: the result is kept as before.
|
||||
s.execute("python.set", json!({"cell": "A1", "code": "[5, 6]", "output": "value"})).unwrap();
|
||||
assert!(code(&s, "A1").unwrap().output.is_some());
|
||||
assert_eq!((v(&s, "A1"), v(&s, "A2"), v(&s, "B1")), (Value::Number(5.0), Value::Number(6.0), Value::Number(6.0)));
|
||||
}
|
||||
@@ -1135,3 +1135,29 @@ fn a_linked_paste_carries_the_files_trust_whatever_it_pastes() {
|
||||
assert!(sheet(&s).cell(b1).is_some_and(|c| c.formula.is_some()), "a linked paste of formats writes a formula");
|
||||
assert_eq!(trust(&s), Trust::FromFile);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sheet_keeps_its_identity_until_it_is_copied_or_read_from_a_file() {
|
||||
let mut s = s();
|
||||
let uids = |s: &Session| s.doc().unwrap().wb.sheets.iter().map(|sh| sh.uid.get()).collect::<Vec<_>>();
|
||||
let first = uids(&s)[0];
|
||||
// Renamed, moved, deleted and brought back by undo: the same sheet.
|
||||
s.execute("home.insertSheet", json!({"name": "Other"})).unwrap();
|
||||
s.execute("sheet.rename", json!({"sheet": "Sheet1", "name": "Data"})).unwrap();
|
||||
s.execute("sheet.move", json!({"sheet": "Data", "to": 1})).unwrap();
|
||||
let data = s.doc().unwrap().wb.sheet_index("Data").unwrap();
|
||||
assert_eq!(uids(&s)[data], first);
|
||||
s.execute("home.deleteSheet", json!({"sheet": "Data"})).unwrap();
|
||||
assert!(!uids(&s).contains(&first));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(uids(&s).contains(&first), "undo brings the same sheet back");
|
||||
// A copy, or a new sheet of the same name, is another sheet.
|
||||
s.execute("sheet.move", json!({"sheet": "Data", "to": 0, "copy": true})).unwrap();
|
||||
let all = uids(&s);
|
||||
assert_eq!(all.len(), 3);
|
||||
assert_eq!(all.iter().collect::<std::collections::BTreeSet<_>>().len(), 3, "{all:?}");
|
||||
// Read from a file: new sheets.
|
||||
let bytes = crate::io::save_bytes(&s.doc().unwrap().wb, "book.xlsx").unwrap();
|
||||
let (read, _) = crate::io::open_bytes("book.xlsx", &bytes).unwrap();
|
||||
assert!(read.sheets.iter().all(|sh| !all.contains(&sh.uid.get())));
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, col_to_letters};
|
||||
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, col_to_letters};
|
||||
use gridcraft_engine::calc::Calc;
|
||||
use gridcraft_engine::model::{CalcMode, Formula, Sheet, SheetViewMode, Workbook};
|
||||
use gridcraft_engine::{EngineError, Session, command_specs, find_command, io};
|
||||
@@ -433,7 +433,12 @@ fn value_diff(a: &Workbook, b: &Workbook, what: &str) -> Option<String> {
|
||||
}
|
||||
for c in keys {
|
||||
let (va, vb) = (x.value(c), y.value(c));
|
||||
if !same_value(&va, &vb) {
|
||||
// A file caches the Python errors as #VALUE! (they aren't error codes of the file
|
||||
// format), in a Python cell and in the formulas that read one; reading it back runs
|
||||
// nothing.
|
||||
let python_error = matches!(va, Value::Error(CellError::Python | CellError::Busy | CellError::Timeout | CellError::Blocked))
|
||||
&& vb == Value::Error(CellError::Value);
|
||||
if !same_value(&va, &vb) && !python_error {
|
||||
let f = x.cell(c).and_then(|c| c.formula.as_ref()).map(|f| format!(" (formula ={})", f.text)).unwrap_or_default();
|
||||
return Some(format!("{}!{c}{f}: {va:?} in memory, {vb:?} after {what}", x.name));
|
||||
}
|
||||
@@ -787,6 +792,8 @@ const CURATED: &[(&str, usize)] = &[
|
||||
("formulas.defineName", 2),
|
||||
("formulas.deleteName", 1),
|
||||
("edit.replace", 1),
|
||||
("python.set", 3),
|
||||
("python.output", 2),
|
||||
];
|
||||
|
||||
fn generate(rng: &mut Rng, s: &Session) -> Step {
|
||||
@@ -820,6 +827,33 @@ fn curated_params(rng: &mut Rng, wb: &Workbook, id: &str) -> Json {
|
||||
p
|
||||
}
|
||||
"range.setValues" => json!({"range": near_cell(rng).a1(), "values": values_2d(rng, wb)}),
|
||||
"python.set" => {
|
||||
// Code with xl() references (which follow structural edits), quotes and line breaks;
|
||||
// sometimes long enough to meet the formula limit.
|
||||
let mut code = match rng.below(5) {
|
||||
0 => format!("xl(\"{}\") + 1", near_range(rng).a1()),
|
||||
1 => format!("a = xl('{}')\nb = \"q\\\"uote\"\na", near_cell(rng).a1()),
|
||||
2 => "[[1, 2], [3, 4]]".to_string(),
|
||||
3 => String::new(),
|
||||
_ => "\"".repeat(rng.int(1, 5000) as usize),
|
||||
};
|
||||
if rng.chance(5) {
|
||||
code.push_str(&"x".repeat(8_100));
|
||||
}
|
||||
let mut p = json!({"cell": near_cell(rng).a1(), "code": code});
|
||||
if rng.chance(30) {
|
||||
p["output"] = json!(if rng.chance(50) { "object" } else { "value" });
|
||||
}
|
||||
p
|
||||
}
|
||||
"python.output" => {
|
||||
// Mostly the cells python.set writes to; a missing mode switches.
|
||||
let mut p = json!({"cell": near_cell(rng).a1()});
|
||||
if rng.chance(50) {
|
||||
p["mode"] = json!(if rng.chance(50) { "object" } else { "value" });
|
||||
}
|
||||
p
|
||||
}
|
||||
"range.fill" => json!({"range": near_range(rng).a1(), "input": input(rng, wb)}),
|
||||
"selection.set" => {
|
||||
let mut p = json!({"range": range(rng)});
|
||||
|
||||
@@ -30,5 +30,6 @@ mod sparse_clear_invalidation;
|
||||
mod stored_list_shape;
|
||||
mod table_resize_overlap;
|
||||
mod table_total_row_last_row;
|
||||
mod trace_error;
|
||||
mod workbook_author;
|
||||
mod wrap_row_height;
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Formulas › Formula Auditing › Error Checking › Trace Error: from a cell showing an error, the
|
||||
//! first reference that shows the same error, back to the cell the error starts in, with arrows
|
||||
//! along the way and from the source's own references into it. The sheet and the expected
|
||||
//! sources and arrows are the ones measured in Excel.
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
/// The measured sheet. A4 is a typed `#N/A`; I1's own formula gives `#REF!`.
|
||||
fn measured() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
for (cell, input) in [
|
||||
("A1", "=1/0"),
|
||||
("A2", "5"),
|
||||
("B1", "=A1+A2"),
|
||||
("C1", "=B1*2"),
|
||||
("D1", "=SUM(C1,A2)"),
|
||||
("A4", "#N/A"),
|
||||
("B4", "=A4"),
|
||||
("C4", "=B4&\"x\""),
|
||||
("A6", "=SQRT(-1)"),
|
||||
("A7", "3"),
|
||||
("B6", "=A6+A7"),
|
||||
("E1", "10"),
|
||||
("E2", "=E1*2"),
|
||||
("F1", "=A1+A4"),
|
||||
("F2", "=A4+A1"),
|
||||
("G1", "=IFERROR(A1,0)"),
|
||||
("G2", "=G1+B1"),
|
||||
("H1", "0"),
|
||||
("H2", "=10/H1"),
|
||||
("H3", "=H2+E1"),
|
||||
("H4", "=H3*1"),
|
||||
("I1", "=#REF!"),
|
||||
("I2", "=I1+1"),
|
||||
("J1", "=XLOOKUP(99,A2:A3,A2:A3)"),
|
||||
("J2", "=J1"),
|
||||
("A9", "=A10"),
|
||||
("A10", "=1/0"),
|
||||
("B9", "=SUM(A9:A10)"),
|
||||
("C9", "=B9"),
|
||||
] {
|
||||
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn trace(s: &mut Session, cell: &str) -> Json {
|
||||
s.execute("formulas.traceError", json!({"cell": cell})).unwrap()
|
||||
}
|
||||
|
||||
/// An end of an arrow as `A1`, or `Sheet2!A1` off Sheet1.
|
||||
fn end(v: &Json) -> String {
|
||||
let at = v["range"].as_str().or(v["cell"].as_str()).unwrap();
|
||||
match v["sheet"].as_str().unwrap() {
|
||||
"Sheet1" => at.to_string(),
|
||||
sheet => format!("{sheet}!{at}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The arrows as `from → to`, with ` (precedent)` after the source's own references.
|
||||
fn arrows(r: &Json) -> Vec<String> {
|
||||
r["arrows"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let kind = match a["kind"].as_str().unwrap() {
|
||||
"error" => "",
|
||||
"precedent" => " (precedent)",
|
||||
k => panic!("unknown kind {k}"),
|
||||
};
|
||||
format!("{} → {}{kind}", end(&a["from"]), end(&a["to"]))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trace_error_finds_the_measured_sources_and_arrows() {
|
||||
let mut s = measured();
|
||||
for (cell, error, source, expected) in [
|
||||
("C1", "#DIV/0!", "A1", vec!["A1 → B1", "B1 → C1"]),
|
||||
("D1", "#DIV/0!", "A1", vec!["A1 → B1", "B1 → C1", "C1 → D1"]),
|
||||
("B1", "#DIV/0!", "A1", vec!["A1 → B1"]),
|
||||
("A1", "#DIV/0!", "A1", vec![]),
|
||||
("A6", "#NUM!", "A6", vec![]),
|
||||
("C4", "#N/A", "A4", vec!["A4 → B4", "B4 → C4"]),
|
||||
// Two errors: the first reference showing the cell's own error.
|
||||
("F1", "#DIV/0!", "A1", vec!["A1 → F1"]),
|
||||
("F2", "#N/A", "A4", vec!["A4 → F2"]),
|
||||
// IFERROR in G1 hides the error, so the error comes through B1.
|
||||
("G2", "#DIV/0!", "A1", vec!["A1 → B1", "B1 → G2"]),
|
||||
// H1 feeds the source H2 without an error of its own; E1 feeds H3 and has no arrow.
|
||||
("H4", "#DIV/0!", "H2", vec!["H1 → H2 (precedent)", "H2 → H3", "H3 → H4"]),
|
||||
("H3", "#DIV/0!", "H2", vec!["H1 → H2 (precedent)", "H2 → H3"]),
|
||||
// XLOOKUP's two A2:A3 arguments make one range arrow into the source.
|
||||
("J2", "#N/A", "J1", vec!["A2:A3 → J1 (precedent)", "J1 → J2"]),
|
||||
// In A9:A10 the first cell showing the error is A9, itself fed by A10.
|
||||
("C9", "#DIV/0!", "A10", vec!["A10 → A9", "A9 → B9", "B9 → C9"]),
|
||||
("I2", "#REF!", "I1", vec!["I1 → I2"]),
|
||||
] {
|
||||
let r = trace(&mut s, cell);
|
||||
assert_eq!((r["sheet"].as_str(), r["cell"].as_str()), (Some("Sheet1"), Some(cell)));
|
||||
assert_eq!(r["error"], error, "{cell}");
|
||||
assert_eq!(end(&r["source"]), source, "{cell}");
|
||||
assert_eq!(arrows(&r), expected, "{cell}");
|
||||
assert_eq!(r["truncated"], false, "{cell}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cell_without_an_error_has_no_source_and_no_arrows() {
|
||||
let mut s = measured();
|
||||
for cell in ["E2", "A2", "Z99", "G1"] {
|
||||
let r = trace(&mut s, cell);
|
||||
assert_eq!(r, json!({"sheet": "Sheet1", "cell": cell, "error": null, "source": null, "arrows": [], "truncated": false}));
|
||||
}
|
||||
// Without `cell`, the active cell.
|
||||
s.execute("selection.set", json!({"range": "C1"})).unwrap();
|
||||
let r = s.execute("formulas.traceError", json!({})).unwrap();
|
||||
assert_eq!((r["cell"].as_str(), arrows(&r).len()), (Some("C1"), 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracing_changes_nothing_and_twice_gives_the_same_arrows() {
|
||||
let mut s = measured();
|
||||
let (wb, undo) = (s.doc().unwrap().wb.clone(), s.doc().unwrap().undo.len());
|
||||
let first = trace(&mut s, "C1");
|
||||
assert_eq!(trace(&mut s, "C1"), first);
|
||||
let d = s.doc().unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&d.wb, &wb), "the workbook is untouched");
|
||||
assert_eq!(d.undo.len(), undo, "no undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cell_on_another_sheet_is_refused_and_bad_params_fail() {
|
||||
let mut s = measured();
|
||||
s.execute("home.insertSheet", json!({"name": "Two"})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
|
||||
for cell in [json!("Two!A1"), json!("not a cell"), json!(5)] {
|
||||
assert!(s.execute("formulas.traceError", json!({"cell": cell})).is_err(), "{cell}");
|
||||
}
|
||||
assert!(s.execute("formulas.traceError", json!({"cell": "Sheet1!C1"})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_error_is_followed_across_sheets() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("home.insertSheet", json!({"name": "Data 2"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "=1/B1"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "0"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C1", "input": "=A1"})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "='Data 2'!C1*2"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C2", "input": "=B2"})).unwrap();
|
||||
let r = trace(&mut s, "C2");
|
||||
assert_eq!(end(&r["source"]), "Data 2!A1");
|
||||
assert_eq!(arrows(&r), ["Data 2!B1 → Data 2!A1 (precedent)", "Data 2!A1 → Data 2!C1", "Data 2!C1 → B2", "B2 → C2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_cells_own_error_is_followed() {
|
||||
let mut s = measured();
|
||||
// K1 shows #DIV/0!; A4 shows #N/A, another error, so K1 is the source and A4 only feeds it.
|
||||
s.execute("cell.set", json!({"cell": "K1", "input": "=IF(ISNA(A4),1/0,0)"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "K2", "input": "=K1+F2"})).unwrap();
|
||||
let r = trace(&mut s, "K2");
|
||||
assert_eq!(r["error"], "#DIV/0!");
|
||||
assert_eq!(end(&r["source"]), "K1");
|
||||
assert_eq!(arrows(&r), ["A4 → K1 (precedent)", "K1 → K2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spilled_error_leads_to_the_references_of_its_array_formula() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 1], [2, 0]]})).unwrap();
|
||||
// D1 spills 1 and #DIV/0! into D1:D2.
|
||||
s.execute("cell.set", json!({"cell": "D1", "input": "=A1:A2/B1:B2"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "E1", "input": "=SUM(D1:D2)"})).unwrap();
|
||||
let r = trace(&mut s, "E1");
|
||||
assert_eq!(end(&r["source"]), "D2", "{r}");
|
||||
assert_eq!(arrows(&r), ["A1:A2 → D2 (precedent)", "B1:B2 → D2 (precedent)", "D2 → E1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_circular_reference_ends_the_walk() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "=B1+1"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "=A1+1"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C1", "input": "=C1"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "D1", "input": "=B1"})).unwrap();
|
||||
let r = trace(&mut s, "D1");
|
||||
assert!(r["error"].is_string(), "{r}");
|
||||
assert_eq!(end(&r["source"]), "A1");
|
||||
assert_eq!(arrows(&r), ["B1 → A1 (precedent)", "A1 → B1", "B1 → D1"]);
|
||||
// A cell that reads itself is its own source.
|
||||
let r = trace(&mut s, "C1");
|
||||
assert_eq!(end(&r["source"]), "C1");
|
||||
assert_eq!(arrows(&r), ["C1 → C1 (precedent)"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_chain_is_walked_to_its_source_and_a_longer_one_stops_at_the_limit() {
|
||||
// On a 1 MiB stack, as on the desktop app's main thread: the walk must not recurse.
|
||||
std::thread::Builder::new()
|
||||
.stack_size(1 << 20)
|
||||
.spawn(|| {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let rows = 10_002;
|
||||
let formulas: Vec<[String; 1]> = (1..=rows).map(|r| [if r == 1 { "=1/0".to_string() } else { format!("=A{}", r - 1) }]).collect();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": formulas})).unwrap();
|
||||
// 10,000 links, the most Trace Error walks: it reaches A1.
|
||||
let r = trace(&mut s, "A10001");
|
||||
assert_eq!((end(&r["source"]), r["truncated"].clone()), ("A1".to_string(), json!(false)));
|
||||
let list = arrows(&r);
|
||||
assert_eq!(list.len(), 10_000);
|
||||
assert_eq!((list.first().map(String::as_str), list.last().map(String::as_str)), (Some("A1 → A2"), Some("A10000 → A10001")));
|
||||
// One more and the walk stops short of the source.
|
||||
let r = trace(&mut s, "A10002");
|
||||
assert_eq!((r["source"].clone(), r["truncated"].clone()), (json!(null), json!(true)));
|
||||
let list = arrows(&r);
|
||||
assert_eq!(list.len(), 10_000);
|
||||
assert_eq!(list.first().map(String::as_str), Some("A2 → A3"), "no arrows into a cell that isn't the source");
|
||||
})
|
||||
.unwrap()
|
||||
.join()
|
||||
.unwrap();
|
||||
}
|
||||
@@ -50,12 +50,27 @@ pub(super) fn median(v: Vec<f64>) -> R<f64> {
|
||||
|
||||
/// Values occurring at least twice, ordered by first occurrence, with their (equal, maximal) count.
|
||||
fn modes(v: &[f64]) -> Vec<f64> {
|
||||
// Index of each distinct number in `counts`, so counting is linear rather than a search of
|
||||
// every distinct number so far. Keyed by bits with -0 folded into 0 (they compare equal); NaN
|
||||
// never equals anything, so it is never merged.
|
||||
let mut index: std::collections::HashMap<u64, usize> = std::collections::HashMap::new();
|
||||
let mut counts: Vec<(f64, usize)> = Vec::new();
|
||||
for &x in v {
|
||||
if let Some(e) = counts.iter_mut().find(|e| e.0 == x) {
|
||||
e.1 += 1;
|
||||
} else {
|
||||
if x.is_nan() {
|
||||
counts.push((x, 1));
|
||||
continue;
|
||||
}
|
||||
let key = if x == 0.0 { 0 } else { x.to_bits() };
|
||||
match index.entry(key) {
|
||||
std::collections::hash_map::Entry::Occupied(e) => {
|
||||
if let Some(c) = counts.get_mut(*e.get()) {
|
||||
c.1 += 1;
|
||||
}
|
||||
}
|
||||
std::collections::hash_map::Entry::Vacant(e) => {
|
||||
e.insert(counts.len());
|
||||
counts.push((x, 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
let best = counts.iter().map(|e| e.1).max().unwrap_or(0);
|
||||
@@ -455,7 +470,10 @@ pub(super) fn frequency(a: &[Arg], _: C) -> R<Value> {
|
||||
order.sort_by(|&i, &j| bins.get(i).copied().unwrap_or(0.0).total_cmp(&bins.get(j).copied().unwrap_or(0.0)));
|
||||
let mut counts = vec![0.0; bins.len() + 1];
|
||||
for x in data {
|
||||
let slot = order.iter().find(|&&i| x <= bins.get(i).copied().unwrap_or(f64::INFINITY)).copied().unwrap_or(bins.len());
|
||||
// `order` sorts the bins ascending, so the bins `x` exceeds come first: binary search for
|
||||
// the first bin at or above `x` (the first of equal bins, as a linear scan would find).
|
||||
let pos = order.partition_point(|&i| bins.get(i).copied().unwrap_or(f64::INFINITY) < x);
|
||||
let slot = order.get(pos).copied().unwrap_or(bins.len());
|
||||
if let Some(c) = counts.get_mut(slot) {
|
||||
*c += 1.0;
|
||||
}
|
||||
|
||||
@@ -617,3 +617,20 @@ fn aggregate_helper() {
|
||||
close(aggregate_values_k(9, &vals, &nv(2.0)), 9.0);
|
||||
is_err(aggregate_values_k(14, &vals, &nv(10.0)), CellError::Num);
|
||||
}
|
||||
|
||||
/// Security: FREQUENCY finds each value's bin by binary search, and MODE counts distinct numbers
|
||||
/// through an index, so neither is quadratic in its inputs. Results are as before: unsorted and
|
||||
/// duplicate bins keep their positions (a value goes to the first of equal bins), and tied modes
|
||||
/// come in first-occurrence order.
|
||||
#[test]
|
||||
fn frequency_and_mode_scale_with_their_inputs() {
|
||||
let f = ev("FREQUENCY", vec![c(&[1.0, 2.0, 2.0, 3.0, 5.0, 9.0, -1.0]), c(&[3.0, 2.0, 2.0, 0.0])]);
|
||||
assert_eq!(rows_of(&f), vec![vec![nv(1.0)], vec![nv(3.0)], vec![nv(0.0)], vec![nv(1.0)], vec![nv(2.0)]]);
|
||||
let data: Vec<f64> = (0..200_000).map(|i| f64::from(i) + 1e6).collect();
|
||||
let bins: Vec<f64> = (0..200_000).map(f64::from).collect();
|
||||
let f = ev("FREQUENCY", vec![c(&data), c(&bins)]);
|
||||
assert_eq!(rows_of(&f).last(), Some(&vec![nv(200_000.0)]));
|
||||
let distinct = ev("MODE", vec![c(&data)]);
|
||||
is_err(distinct, CellError::NA);
|
||||
assert_eq!(rows_of(&ev("MODE.MULT", vec![c(&[3.0, 0.0, 1.0, -0.0, 3.0, 1.0])])), vec![vec![nv(3.0)], vec![nv(0.0)], vec![nv(1.0)]]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "gridcraft-math"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "GridCraft equations: LaTeX laid out as plain glyph, rule and path data, and OMML for xlsx"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
ratex-parser = { workspace = true }
|
||||
ratex-layout = { workspace = true }
|
||||
ratex-types = { workspace = true }
|
||||
ratex-font = { workspace = true }
|
||||
@@ -0,0 +1,533 @@
|
||||
//! GridCraft equations. LaTeX source is laid out by RaTeX (`ratex-parser`, `ratex-layout`; MIT)
|
||||
//! into a [`MathList`]: plain glyph, rule and path data that the UI and the PDF writer paint
|
||||
//! without calling RaTeX again.
|
||||
//!
|
||||
//! RaTeX is young (0.1.x), so it only ever runs inside this crate's entry points ([`layout`],
|
||||
//! [`to_omml`], [`check`]). Each one caps its input (4 KB, nesting 32) and runs under
|
||||
//! `catch_unwind`, so a stray panic becomes a [`MathError`]. See the RaTeX gate in
|
||||
//! `docs/decisions.md` and `tests/gate.rs`.
|
||||
//!
|
||||
//! Units are em: one em is the equation's font size. Coordinates run right and down from the
|
||||
//! top-left of the list's box; the baseline is at `y = height`.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod omml;
|
||||
|
||||
use std::ops::Range;
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
|
||||
use ratex_font::FontId;
|
||||
use ratex_types::color::Color;
|
||||
use ratex_types::display_item::{DisplayItem, DisplayList};
|
||||
use ratex_types::path_command::PathCommand;
|
||||
|
||||
pub use omml::to_omml;
|
||||
|
||||
/// Longest source accepted, in bytes.
|
||||
pub const MAX_SOURCE_BYTES: usize = 4096;
|
||||
/// Deepest nesting of `{…}`, `\left…\right` and `\begin…\end` accepted.
|
||||
pub const MAX_DEPTH: usize = 32;
|
||||
/// Largest coordinate or size accepted in a layout, in em. `\rule{1e308em}{1em}` lays out; this
|
||||
/// stops it from reaching a painter.
|
||||
pub const MAX_EM: f32 = 10_000.0;
|
||||
/// Most items one equation may produce.
|
||||
pub const MAX_ITEMS: usize = 50_000;
|
||||
|
||||
/// The error for a source over [`MAX_SOURCE_BYTES`]. It is one shared value so that a cache can
|
||||
/// answer for such a source by its length alone, without reading it.
|
||||
pub static SOURCE_TOO_LONG: std::sync::LazyLock<MathError> =
|
||||
std::sync::LazyLock::new(|| MathError::new(format!("An equation can be at most {MAX_SOURCE_BYTES} bytes long."), None));
|
||||
|
||||
/// Why an equation could not be laid out.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct MathError {
|
||||
/// The offending source, as a byte range on char boundaries, when known.
|
||||
pub span: Option<Range<usize>>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl MathError {
|
||||
fn new(message: impl Into<String>, span: Option<Range<usize>>) -> MathError {
|
||||
MathError { span, message: message.into() }
|
||||
}
|
||||
|
||||
/// The 1-based character position of the error in `source`, when known.
|
||||
pub fn char_pos(&self, source: &str) -> Option<usize> {
|
||||
let start = self.span.as_ref()?.start;
|
||||
Some(source.char_indices().take_while(|(i, _)| *i < start).count() + 1)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for MathError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for MathError {}
|
||||
|
||||
/// A laid-out equation.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct MathList {
|
||||
pub items: Vec<Item>,
|
||||
/// Advance width, in em.
|
||||
pub width: f32,
|
||||
/// Height above the baseline, in em.
|
||||
pub height: f32,
|
||||
/// Depth below the baseline, in em.
|
||||
pub depth: f32,
|
||||
}
|
||||
|
||||
impl MathList {
|
||||
/// Total height (height plus depth), in em.
|
||||
pub fn total_height(&self) -> f32 {
|
||||
self.height + self.depth
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Item {
|
||||
Glyph(Glyph),
|
||||
Rule(Rule),
|
||||
Path(Path),
|
||||
}
|
||||
|
||||
/// The typeface a glyph asks for. Painters map it onto the fonts they have.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub enum Face {
|
||||
#[default]
|
||||
Roman,
|
||||
Italic,
|
||||
Bold,
|
||||
BoldItalic,
|
||||
Sans,
|
||||
SansBold,
|
||||
SansItalic,
|
||||
Mono,
|
||||
/// A large operator or delimiter (TeX's Size1 to Size4 fonts): drawn stretched to the glyph's
|
||||
/// box rather than at the run's font size.
|
||||
Large(u8),
|
||||
}
|
||||
|
||||
impl Face {
|
||||
pub fn is_italic(self) -> bool {
|
||||
matches!(self, Face::Italic | Face::BoldItalic | Face::SansItalic)
|
||||
}
|
||||
pub fn is_bold(self) -> bool {
|
||||
matches!(self, Face::Bold | Face::BoldItalic | Face::SansBold)
|
||||
}
|
||||
}
|
||||
|
||||
/// One character.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Glyph {
|
||||
/// Left edge of the glyph's box.
|
||||
pub x: f32,
|
||||
/// The glyph's baseline.
|
||||
pub y: f32,
|
||||
/// Font size, in em of the equation (1 for the main line, smaller in scripts).
|
||||
pub size: f32,
|
||||
/// The character to draw.
|
||||
pub ch: char,
|
||||
/// A plainer character to draw when no font has `ch` (`R` for `ℝ`), before giving up.
|
||||
pub alt: Option<char>,
|
||||
pub face: Face,
|
||||
/// The glyph's box (TeX metrics), already scaled by `size`: advance, height above and depth
|
||||
/// below the baseline. A painter without the glyph draws a `[?]` box this size.
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
pub depth: f32,
|
||||
/// An explicit colour (`\color`); `None` is the equation's own ("Automatic") colour.
|
||||
pub color: Option<[u8; 3]>,
|
||||
}
|
||||
|
||||
/// A filled rectangle (fraction bars, radical vinculums, table rules, box borders).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Rule {
|
||||
pub x: f32,
|
||||
/// Top edge.
|
||||
pub y: f32,
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
pub color: Option<[u8; 3]>,
|
||||
}
|
||||
|
||||
/// A vector outline (stretchy braces, arrows, radicals), relative to `(x, y)`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Path {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub commands: Vec<PathCmd>,
|
||||
pub fill: bool,
|
||||
pub color: Option<[u8; 3]>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum PathCmd {
|
||||
MoveTo(f32, f32),
|
||||
LineTo(f32, f32),
|
||||
QuadTo(f32, f32, f32, f32),
|
||||
CubicTo(f32, f32, f32, f32, f32, f32),
|
||||
Close,
|
||||
}
|
||||
|
||||
/// Lays `latex` out. Errors carry the offending span when RaTeX reports one.
|
||||
pub fn layout(latex: &str) -> Result<MathList, MathError> {
|
||||
check_caps(latex)?;
|
||||
guarded(|| {
|
||||
let nodes = parse(latex)?;
|
||||
let lb = ratex_layout::layout(&nodes, &ratex_layout::LayoutOptions::default());
|
||||
convert(&ratex_layout::to_display_list(&lb))
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `latex` parses, without laying it out (cheap enough to run on every keystroke).
|
||||
pub fn check(latex: &str) -> Result<(), MathError> {
|
||||
check_caps(latex)?;
|
||||
guarded(|| parse(latex).map(|_| ()))
|
||||
}
|
||||
|
||||
/// The equation gallery (Insert › Equation): GridCraft's own examples, as (name, LaTeX).
|
||||
pub const GALLERY: &[(&str, &str)] = &[
|
||||
("Quadratic formula", "x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}"),
|
||||
("Pythagoras", "a^2 + b^2 = c^2"),
|
||||
("Binomial theorem", "(x + a)^n = \\sum_{k=0}^{n} \\binom{n}{k} x^k a^{n-k}"),
|
||||
("Area of a circle", "A = \\pi r^2"),
|
||||
("Euler's identity", "e^{i\\pi} + 1 = 0"),
|
||||
("2\u{00D7}2 matrix", "\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}"),
|
||||
];
|
||||
|
||||
/// Runs `f` so that a panic inside it becomes an error.
|
||||
fn guarded<T>(f: impl FnOnce() -> Result<T, MathError>) -> Result<T, MathError> {
|
||||
catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|_| Err(MathError::new("This equation could not be laid out.", None)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Test hook: makes the next guarded parse panic, to prove the guard holds.
|
||||
static INJECT_PANIC: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
fn parse(latex: &str) -> Result<Vec<ratex_parser::ParseNode>, MathError> {
|
||||
#[cfg(test)]
|
||||
if INJECT_PANIC.with(|p| p.replace(false)) {
|
||||
panic!("injected");
|
||||
}
|
||||
ratex_parser::parse(latex).map_err(|e| {
|
||||
let span = e.loc.and_then(|l| {
|
||||
let (s, t) = (l.start.min(latex.len()), l.end.min(latex.len()));
|
||||
(latex.is_char_boundary(s) && latex.is_char_boundary(t)).then_some(s..t.max(s))
|
||||
});
|
||||
MathError::new(e.message, span)
|
||||
})
|
||||
}
|
||||
|
||||
/// The size and nesting caps, checked before RaTeX sees the source.
|
||||
fn check_caps(latex: &str) -> Result<(), MathError> {
|
||||
if latex.len() > MAX_SOURCE_BYTES {
|
||||
return Err(SOURCE_TOO_LONG.clone());
|
||||
}
|
||||
let mut depth = 0usize;
|
||||
let mut chars = latex.char_indices().peekable();
|
||||
while let Some((i, c)) = chars.next() {
|
||||
let open = match c {
|
||||
'\\' => {
|
||||
// A control word, or an escaped character (`\{` is not a group).
|
||||
let mut word = String::new();
|
||||
while let Some(&(_, n)) = chars.peek() {
|
||||
if n.is_ascii_alphabetic() {
|
||||
word.push(n);
|
||||
chars.next();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if word.is_empty() {
|
||||
chars.next();
|
||||
continue;
|
||||
}
|
||||
match word.as_str() {
|
||||
"left" | "begin" => Some(true),
|
||||
"right" | "end" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
'%' => {
|
||||
// A comment runs to the end of the line.
|
||||
for (_, n) in chars.by_ref() {
|
||||
if n == '\n' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
'{' => Some(true),
|
||||
'}' => Some(false),
|
||||
_ => None,
|
||||
};
|
||||
match open {
|
||||
Some(true) => {
|
||||
depth += 1;
|
||||
if depth > MAX_DEPTH {
|
||||
return Err(MathError::new(format!("Equations can nest at most {MAX_DEPTH} levels deep."), Some(i..i + c.len_utf8())));
|
||||
}
|
||||
}
|
||||
Some(false) => depth = depth.saturating_sub(1),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn color(c: &Color) -> Option<[u8; 3]> {
|
||||
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
let rgb = [q(c.r), q(c.g), q(c.b)];
|
||||
(rgb != [0, 0, 0]).then_some(rgb)
|
||||
}
|
||||
|
||||
/// A finite value within the caps, as `f32`.
|
||||
fn em(v: f64) -> Result<f32, MathError> {
|
||||
if v.is_finite() && v.abs() <= f64::from(MAX_EM) { Ok(v as f32) } else { Err(MathError::new("This equation is too large to draw.", None)) }
|
||||
}
|
||||
|
||||
fn convert(dl: &DisplayList) -> Result<MathList, MathError> {
|
||||
if dl.items.len() > MAX_ITEMS {
|
||||
return Err(MathError::new("This equation is too large to draw.", None));
|
||||
}
|
||||
let mut items = Vec::with_capacity(dl.items.len());
|
||||
for it in &dl.items {
|
||||
items.push(match it {
|
||||
DisplayItem::GlyphPath { x, y, scale, font, char_code, color: c } => {
|
||||
Item::Glyph(glyph(em(*x)?, em(*y)?, em(*scale)?, font, *char_code, color(c))?)
|
||||
}
|
||||
DisplayItem::Line { x, y, width, thickness, color: c, .. } => {
|
||||
let t = em(*thickness)?;
|
||||
Item::Rule(Rule { x: em(*x)?, y: em(*y)? - t / 2.0, width: em(*width)?, height: t, color: color(c) })
|
||||
}
|
||||
DisplayItem::Rect { x, y, width, height, color: c } => {
|
||||
Item::Rule(Rule { x: em(*x)?, y: em(*y)?, width: em(*width)?, height: em(*height)?, color: color(c) })
|
||||
}
|
||||
DisplayItem::Path { x, y, commands, fill, color: c } => {
|
||||
let mut cmds = Vec::with_capacity(commands.len());
|
||||
for pc in commands {
|
||||
cmds.push(match *pc {
|
||||
PathCommand::MoveTo { x, y } => PathCmd::MoveTo(em(x)?, em(y)?),
|
||||
PathCommand::LineTo { x, y } => PathCmd::LineTo(em(x)?, em(y)?),
|
||||
PathCommand::QuadTo { x1, y1, x, y } => PathCmd::QuadTo(em(x1)?, em(y1)?, em(x)?, em(y)?),
|
||||
PathCommand::CubicTo { x1, y1, x2, y2, x, y } => PathCmd::CubicTo(em(x1)?, em(y1)?, em(x2)?, em(y2)?, em(x)?, em(y)?),
|
||||
PathCommand::Close => PathCmd::Close,
|
||||
});
|
||||
}
|
||||
Item::Path(Path { x: em(*x)?, y: em(*y)?, commands: cmds, fill: *fill, color: color(c) })
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(MathList { items, width: em(dl.width)?.max(0.0), height: em(dl.height)?, depth: em(dl.depth)? })
|
||||
}
|
||||
|
||||
fn glyph(x: f32, y: f32, size: f32, font: &str, code: u32, color: Option<[u8; 3]>) -> Result<Glyph, MathError> {
|
||||
let fid = FontId::parse(font).unwrap_or(FontId::MainRegular);
|
||||
let face = match fid {
|
||||
FontId::MainBold | FontId::FrakturBold => Face::Bold,
|
||||
FontId::MainItalic | FontId::MathItalic => Face::Italic,
|
||||
FontId::MainBoldItalic | FontId::MathBoldItalic => Face::BoldItalic,
|
||||
FontId::SansSerifRegular => Face::Sans,
|
||||
FontId::SansSerifBold => Face::SansBold,
|
||||
FontId::SansSerifItalic => Face::SansItalic,
|
||||
FontId::TypewriterRegular => Face::Mono,
|
||||
FontId::Size1Regular => Face::Large(1),
|
||||
FontId::Size2Regular => Face::Large(2),
|
||||
FontId::Size3Regular => Face::Large(3),
|
||||
FontId::Size4Regular => Face::Large(4),
|
||||
_ => Face::Roman,
|
||||
};
|
||||
let raw = char::from_u32(code).unwrap_or('\u{FFFD}');
|
||||
// Blackboard, script and Fraktur letters sit at ASCII slots in TeX's fonts; draw the Unicode
|
||||
// letter, with the ASCII one as the fallback.
|
||||
let (ch, alt) = match (fid, raw) {
|
||||
(FontId::AmsRegular, c @ ('A'..='Z' | 'k')) => (styled(c, Style::DoubleStruck), Some(c)),
|
||||
(FontId::CaligraphicRegular | FontId::ScriptRegular, c @ ('A'..='Z' | 'a'..='z')) => (styled(c, Style::Script), Some(c)),
|
||||
(FontId::FrakturRegular, c @ ('A'..='Z' | 'a'..='z')) => (styled(c, Style::Fraktur), Some(c)),
|
||||
(FontId::FrakturBold, c @ ('A'..='Z' | 'a'..='z')) => (styled(c, Style::BoldFraktur), Some(c)),
|
||||
(_, c) if (0x1D400..=0x1D7FF).contains(&code) => (c, Some(ratex_font::katex_ttf_glyph_char(fid, code))),
|
||||
(_, c) => (c, None),
|
||||
};
|
||||
let m = ratex_font::font_and_metric_for_mathematical_alphanumeric(code)
|
||||
.and_then(|(f, mc)| ratex_font::get_char_metrics(f, mc))
|
||||
.or_else(|| ratex_font::get_char_metrics(fid, code))
|
||||
.or_else(|| ratex_font::get_char_metrics_with_fallback(fid, code));
|
||||
let (w, h, d) = m.map_or((0.5, 0.7, 0.0), |m| (m.width + m.italic, m.height, m.depth));
|
||||
Ok(Glyph { x, y, size, ch, alt, face, width: em(w)? * size, height: em(h)? * size, depth: em(d)? * size, color })
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Style {
|
||||
DoubleStruck,
|
||||
Script,
|
||||
Fraktur,
|
||||
BoldFraktur,
|
||||
}
|
||||
|
||||
/// The Mathematical Alphanumeric Symbols letter for `c` (Unicode 15, chapter 22.2), using the
|
||||
/// Letterlike Symbols block where Unicode put a letter before the alphanumeric block existed.
|
||||
fn styled(c: char, style: Style) -> char {
|
||||
let letterlike = match (style, c) {
|
||||
(Style::DoubleStruck, 'C') => Some('\u{2102}'),
|
||||
(Style::DoubleStruck, 'H') => Some('\u{210D}'),
|
||||
(Style::DoubleStruck, 'N') => Some('\u{2115}'),
|
||||
(Style::DoubleStruck, 'P') => Some('\u{2119}'),
|
||||
(Style::DoubleStruck, 'Q') => Some('\u{211A}'),
|
||||
(Style::DoubleStruck, 'R') => Some('\u{211D}'),
|
||||
(Style::DoubleStruck, 'Z') => Some('\u{2124}'),
|
||||
(Style::Script, 'B') => Some('\u{212C}'),
|
||||
(Style::Script, 'E') => Some('\u{2130}'),
|
||||
(Style::Script, 'F') => Some('\u{2131}'),
|
||||
(Style::Script, 'H') => Some('\u{210B}'),
|
||||
(Style::Script, 'I') => Some('\u{2110}'),
|
||||
(Style::Script, 'L') => Some('\u{2112}'),
|
||||
(Style::Script, 'M') => Some('\u{2133}'),
|
||||
(Style::Script, 'R') => Some('\u{211B}'),
|
||||
(Style::Script, 'e') => Some('\u{212F}'),
|
||||
(Style::Script, 'g') => Some('\u{210A}'),
|
||||
(Style::Script, 'o') => Some('\u{2134}'),
|
||||
(Style::Fraktur, 'C') => Some('\u{212D}'),
|
||||
(Style::Fraktur, 'H') => Some('\u{210C}'),
|
||||
(Style::Fraktur, 'I') => Some('\u{2111}'),
|
||||
(Style::Fraktur, 'R') => Some('\u{211C}'),
|
||||
(Style::Fraktur, 'Z') => Some('\u{2128}'),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(l) = letterlike {
|
||||
return l;
|
||||
}
|
||||
let (upper, lower) = match style {
|
||||
Style::DoubleStruck => (0x1D538, 0x1D552),
|
||||
Style::Script => (0x1D49C, 0x1D4B6),
|
||||
Style::Fraktur => (0x1D504, 0x1D51E),
|
||||
Style::BoldFraktur => (0x1D56C, 0x1D586),
|
||||
};
|
||||
let code = match c {
|
||||
'A'..='Z' => upper + (c as u32 - 'A' as u32),
|
||||
'a'..='z' => lower + (c as u32 - 'a' as u32),
|
||||
_ => return c,
|
||||
};
|
||||
char::from_u32(code).unwrap_or(c)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn glyphs(l: &MathList) -> String {
|
||||
l.items.iter().filter_map(|i| if let Item::Glyph(g) = i { Some(g.ch) } else { None }).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boxes_of_a_simple_line() {
|
||||
let l = layout("x+1").unwrap();
|
||||
assert_eq!(glyphs(&l), "x+1");
|
||||
assert!(l.width > 1.5 && l.width < 3.0, "{}", l.width);
|
||||
assert!(l.height > 0.4 && l.height < 1.0);
|
||||
let Some(Item::Glyph(x)) = l.items.first() else { panic!("a glyph first") };
|
||||
assert_eq!(x.face, Face::Italic, "a letter is a math italic");
|
||||
assert!((x.y - l.height).abs() < 1e-4, "on the baseline");
|
||||
assert!(x.width > 0.3 && x.height > 0.3);
|
||||
let one = l.items.iter().find_map(|i| if let Item::Glyph(g) = i { (g.ch == '1').then_some(g) } else { None }).unwrap();
|
||||
assert_eq!(one.face, Face::Roman);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fractions_roots_and_scripts() {
|
||||
let f = layout("\\frac{a}{b}").unwrap();
|
||||
let rule = f.items.iter().find_map(|i| if let Item::Rule(r) = i { Some(r) } else { None }).expect("a fraction bar");
|
||||
assert!(rule.width > 0.3 && rule.height > 0.0 && rule.height < 0.1);
|
||||
let (a, b) = match &f.items.iter().filter_map(|i| if let Item::Glyph(g) = i { Some(g.clone()) } else { None }).collect::<Vec<_>>()[..] {
|
||||
[a, b] => (a.clone(), b.clone()),
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
assert!(a.y < rule.y && b.y > rule.y + rule.height, "numerator above, denominator below");
|
||||
assert!(f.depth > 0.3);
|
||||
let s = layout("x^2").unwrap();
|
||||
let g: Vec<&Glyph> = s.items.iter().filter_map(|i| if let Item::Glyph(g) = i { Some(g) } else { None }).collect();
|
||||
assert!(g[1].size < 1.0 && g[1].y < g[0].y, "the exponent is smaller and raised");
|
||||
let r = layout("\\sqrt{x}").unwrap();
|
||||
assert!(glyphs(&r).contains('x'));
|
||||
assert!(r.items.len() >= 2);
|
||||
let big = layout("\\displaystyle\\sum_{i=1}^n i").unwrap();
|
||||
assert!(big.items.iter().any(|i| matches!(i, Item::Glyph(Glyph { ch: '∑', face: Face::Large(_), .. }))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_letters_map_to_unicode_with_a_plain_fallback() {
|
||||
let l = layout("\\mathbb{R}\\mathbb{Q}\\mathcal{L}\\mathfrak{g}").unwrap();
|
||||
let g: Vec<(char, Option<char>)> = l.items.iter().filter_map(|i| if let Item::Glyph(g) = i { Some((g.ch, g.alt)) } else { None }).collect();
|
||||
assert_eq!(g, vec![('ℝ', Some('R')), ('ℚ', Some('Q')), ('ℒ', Some('L')), ('𝔤', Some('g'))]);
|
||||
assert_eq!(styled('A', Style::DoubleStruck), '𝔸');
|
||||
assert_eq!(styled('b', Style::Script), '𝒷');
|
||||
assert_eq!(styled('7', Style::Fraktur), '7');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colours_other_than_black_are_kept() {
|
||||
let l = layout("{\\color{red}x}y").unwrap();
|
||||
let c: Vec<Option<[u8; 3]>> = l.items.iter().filter_map(|i| if let Item::Glyph(g) = i { Some(g.color) } else { None }).collect();
|
||||
assert_eq!(c, vec![Some([255, 0, 0]), None]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_point_at_the_source() {
|
||||
let e = layout("a + \\frac{1}{").unwrap_err();
|
||||
assert!(!e.message.is_empty());
|
||||
let e = layout("x + \\foo").unwrap_err();
|
||||
assert_eq!(e.span, Some(4..8), "{e:?}");
|
||||
assert_eq!(e.char_pos("x + \\foo"), Some(5));
|
||||
let e = layout("é + \\foo").unwrap_err();
|
||||
assert_eq!(e.char_pos("é + \\foo"), Some(5), "positions count characters, not bytes");
|
||||
assert!(check("\\frac{a}{b}").is_ok());
|
||||
assert!(check("\\frac{a}{").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_input_is_refused_without_panicking() {
|
||||
let e = layout(&"x".repeat(MAX_SOURCE_BYTES + 1)).unwrap_err();
|
||||
assert!(e.message.contains("4096"));
|
||||
assert!(layout(&"x".repeat(MAX_SOURCE_BYTES)).is_ok());
|
||||
let deep = format!("{}x{}", "{".repeat(33), "}".repeat(33));
|
||||
assert_eq!(layout(&deep).unwrap_err().span, Some(32..33));
|
||||
assert!(layout(&format!("{}x{}", "\\left(".repeat(40), "\\right)".repeat(40))).is_err());
|
||||
// Escaped braces and comments are not groups.
|
||||
assert!(check_caps(&"\\{".repeat(100)).is_ok());
|
||||
assert!(check_caps(&format!("x % {}\n", "{".repeat(100))).is_ok());
|
||||
assert!(layout("\\rule{99999999em}{1em}").unwrap_err().message.contains("too large"));
|
||||
assert!(layout("\\kern99999999em x").is_err());
|
||||
assert!(layout("\\def\\a{\\a\\a}\\a").is_err());
|
||||
for s in ["", " ", "\\", "{", "}", "^", "_", "&", "\\\\", "\u{0}", "\u{FFFF}", "\u{1F600}", "$", "#", "\\begin{matrix}", "\\end{matrix}"] {
|
||||
let _ = layout(s);
|
||||
let _ = check(s);
|
||||
let _ = to_omml(s, 11.0, None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_injected_panic_is_an_error_not_a_crash() {
|
||||
INJECT_PANIC.with(|p| p.set(true));
|
||||
let e = layout("x").unwrap_err();
|
||||
assert_eq!(e.message, "This equation could not be laid out.");
|
||||
assert!(layout("x").is_ok(), "the next call is unaffected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_gallery_lays_out_and_converts() {
|
||||
for (name, src) in GALLERY {
|
||||
assert!(layout(src).is_ok_and(|l| !l.items.is_empty()), "{name}");
|
||||
assert!(to_omml(src, 11.0, None).is_ok(), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_source_is_an_empty_list() {
|
||||
let l = layout("").unwrap();
|
||||
assert!(l.items.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
//! LaTeX to OMML, the open Office Math Markup of ECMA-376 Part 1 §22.1, for the common subset:
|
||||
//! runs, fractions and binomials, roots, scripts, big operators with limits, functions and
|
||||
//! limits, delimiters, matrices, accents, bars, braces and font styles. Anything else is an
|
||||
//! error, and the caller saves the equation as text only.
|
||||
|
||||
use ratex_parser::ParseNode;
|
||||
use ratex_parser::parse_node::Mode;
|
||||
|
||||
use crate::MathError;
|
||||
|
||||
/// Deepest tree walked (RaTeX already refuses input nested deeper than 32).
|
||||
const MAX_WALK: usize = 96;
|
||||
|
||||
/// `latex` as an `<m:oMath>` element (prefix `m` for the OMML namespace and `a` for DrawingML,
|
||||
/// declared by the caller), with runs at `size_pt` points in the `ink` colour (none: the
|
||||
/// paragraph's own).
|
||||
pub fn to_omml(latex: &str, size_pt: f32, ink: Option<[u8; 3]>) -> Result<String, MathError> {
|
||||
crate::check_caps(latex)?;
|
||||
crate::guarded(|| {
|
||||
let nodes = crate::parse(latex)?;
|
||||
let sz = if size_pt.is_finite() { (size_pt * 100.0).round().clamp(100.0, 400_000.0) as u32 } else { 1100 };
|
||||
let fill = ink.map(|[r, g, b]| format!("<a:solidFill><a:srgbClr val=\"{r:02X}{g:02X}{b:02X}\"/></a:solidFill>")).unwrap_or_default();
|
||||
let mut w = Writer { out: String::from("<m:oMath>"), sz, fill };
|
||||
w.seq(&nodes, &Style::default(), 0)?;
|
||||
w.out.push_str("</m:oMath>");
|
||||
Ok(w.out)
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct Style {
|
||||
/// `m:scr` (script) value: double-struck, script, fraktur, sans-serif, monospace.
|
||||
scr: Option<&'static str>,
|
||||
/// `m:sty` value: p (plain), b (bold), i, bi.
|
||||
sty: Option<&'static str>,
|
||||
/// Ordinary text (`\text`): `m:nor`.
|
||||
normal: bool,
|
||||
}
|
||||
|
||||
struct Writer {
|
||||
out: String,
|
||||
sz: u32,
|
||||
/// The DrawingML fill of every run (`<a:solidFill>…</a:solidFill>`), or empty.
|
||||
fill: String,
|
||||
}
|
||||
|
||||
fn unsupported(what: &str) -> MathError {
|
||||
MathError::new(format!("{what} can't be saved as an Office equation."), None)
|
||||
}
|
||||
|
||||
/// Whether XML 1.0 can carry `c`: the same set the xlsx crate's escapes keep, so the OMML is
|
||||
/// well formed wherever the drawing part embeds it.
|
||||
fn is_xml_char(c: char) -> bool {
|
||||
matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}')
|
||||
}
|
||||
|
||||
/// `s` escaped for element text; characters XML cannot carry (C0 controls other than tab, newline
|
||||
/// and carriage return, and U+FFFE and U+FFFF) are dropped, as the xlsx crate's `esc` drops them.
|
||||
fn esc(s: &str) -> String {
|
||||
let mut o = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => o.push_str("&"),
|
||||
'<' => o.push_str("<"),
|
||||
'>' => o.push_str(">"),
|
||||
'"' => o.push_str("""),
|
||||
'\r' => o.push_str(" "),
|
||||
c if is_xml_char(c) => o.push(c),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// The character a symbol name (`\alpha`, `(`, `\langle`) stands for.
|
||||
fn symbol(name: &str, mode: Mode) -> Option<String> {
|
||||
let m = if mode == Mode::Text { ratex_font::symbols::Mode::Text } else { ratex_font::symbols::Mode::Math };
|
||||
if let Some(c) = ratex_font::symbols::get_symbol(name, m).and_then(|s| s.codepoint) {
|
||||
return Some(c.to_string());
|
||||
}
|
||||
if !name.starts_with('\\') {
|
||||
return Some(name.to_string());
|
||||
}
|
||||
match name {
|
||||
"\\{" | "\\lbrace" => Some("{".into()),
|
||||
"\\}" | "\\rbrace" => Some("}".into()),
|
||||
"\\|" | "\\Vert" => Some("‖".into()),
|
||||
"\\vert" => Some("|".into()),
|
||||
"\\langle" => Some("⟨".into()),
|
||||
"\\rangle" => Some("⟩".into()),
|
||||
"\\lfloor" => Some("⌊".into()),
|
||||
"\\rfloor" => Some("⌋".into()),
|
||||
"\\lceil" => Some("⌈".into()),
|
||||
"\\rceil" => Some("⌉".into()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A delimiter's character; `.` is no delimiter.
|
||||
fn delim(d: &str) -> Result<String, MathError> {
|
||||
if d == "." {
|
||||
return Ok(String::new());
|
||||
}
|
||||
if d.len() == 1 && d.is_ascii() {
|
||||
return Ok(d.to_string());
|
||||
}
|
||||
symbol(d, Mode::Math).ok_or_else(|| unsupported(&format!("The delimiter {d}")))
|
||||
}
|
||||
|
||||
fn is_big_op(n: &ParseNode) -> Option<String> {
|
||||
match n {
|
||||
ParseNode::Op { symbol: true, name: Some(name), .. } => symbol(name, Mode::Math),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_rel(n: &ParseNode) -> bool {
|
||||
matches!(n, ParseNode::Atom { family: ratex_parser::parse_node::AtomFamily::Rel, .. })
|
||||
}
|
||||
|
||||
impl Writer {
|
||||
fn run(&mut self, text: &str, st: &Style) {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.out.push_str("<m:r>");
|
||||
if st.scr.is_some() || st.sty.is_some() || st.normal {
|
||||
self.out.push_str("<m:rPr>");
|
||||
if st.normal {
|
||||
self.out.push_str("<m:nor/>");
|
||||
}
|
||||
if let Some(s) = st.scr {
|
||||
self.out.push_str(&format!("<m:scr m:val=\"{s}\"/>"));
|
||||
}
|
||||
if let Some(s) = st.sty {
|
||||
self.out.push_str(&format!("<m:sty m:val=\"{s}\"/>"));
|
||||
}
|
||||
self.out.push_str("</m:rPr>");
|
||||
}
|
||||
// Word trims a run's text unless it is marked, so a run that is or ends in whitespace
|
||||
// (the space after `\text{if }`) says so.
|
||||
let space = if text.starts_with(char::is_whitespace) || text.ends_with(char::is_whitespace) { " xml:space=\"preserve\"" } else { "" };
|
||||
self.out.push_str(&format!(
|
||||
"<a:rPr lang=\"en-US\" sz=\"{}\">{}<a:latin typeface=\"Cambria Math\"/></a:rPr><m:t{}>{}</m:t></m:r>",
|
||||
self.sz,
|
||||
self.fill,
|
||||
space,
|
||||
esc(text)
|
||||
));
|
||||
}
|
||||
|
||||
/// `<tag>…</tag>` around the nodes.
|
||||
fn wrap(&mut self, tag: &str, nodes: &[ParseNode], st: &Style, depth: usize) -> Result<(), MathError> {
|
||||
self.out.push_str(&format!("<{tag}>"));
|
||||
self.seq(nodes, st, depth)?;
|
||||
self.out.push_str(&format!("</{tag}>"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn wrap1(&mut self, tag: &str, node: Option<&ParseNode>, st: &Style, depth: usize) -> Result<(), MathError> {
|
||||
match node {
|
||||
Some(n) => self.wrap(tag, std::slice::from_ref(n), st, depth),
|
||||
None => {
|
||||
self.out.push_str(&format!("<{tag}/>"));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn seq(&mut self, nodes: &[ParseNode], st: &Style, depth: usize) -> Result<(), MathError> {
|
||||
if depth > MAX_WALK {
|
||||
return Err(unsupported("An equation this deep"));
|
||||
}
|
||||
let mut i = 0;
|
||||
while let Some(n) = nodes.get(i) {
|
||||
i += 1;
|
||||
// A big operator takes what follows, up to the next relation, as its operand.
|
||||
let (op, sub, sup) = match n {
|
||||
ParseNode::SupSub { base: Some(b), sub, sup, .. } => (is_big_op(b), sub.as_deref(), sup.as_deref()),
|
||||
other => (is_big_op(other), None, None),
|
||||
};
|
||||
if let Some(chr) = op {
|
||||
let end = nodes.get(i..).and_then(|rest| rest.iter().position(is_rel)).map_or(nodes.len(), |p| i + p);
|
||||
let limits = match n {
|
||||
ParseNode::SupSub { base: Some(b), .. } => matches!(**b, ParseNode::Op { limits: true, .. }),
|
||||
_ => false,
|
||||
};
|
||||
self.out.push_str(&format!(
|
||||
"<m:nary><m:naryPr><m:chr m:val=\"{}\"/><m:limLoc m:val=\"{}\"/>{}{}</m:naryPr>",
|
||||
esc(&chr),
|
||||
if limits { "undOvr" } else { "subSup" },
|
||||
if sub.is_none() { "<m:subHide m:val=\"1\"/>" } else { "" },
|
||||
if sup.is_none() { "<m:supHide m:val=\"1\"/>" } else { "" },
|
||||
));
|
||||
self.wrap1("m:sub", sub, st, depth + 1)?;
|
||||
self.wrap1("m:sup", sup, st, depth + 1)?;
|
||||
self.wrap("m:e", nodes.get(i..end).unwrap_or_default(), st, depth + 1)?;
|
||||
self.out.push_str("</m:nary>");
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
self.node(n, st, depth + 1)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn node(&mut self, n: &ParseNode, st: &Style, depth: usize) -> Result<(), MathError> {
|
||||
if depth > MAX_WALK {
|
||||
return Err(unsupported("An equation this deep"));
|
||||
}
|
||||
match n {
|
||||
ParseNode::Atom { text, mode, .. }
|
||||
| ParseNode::MathOrd { text, mode, .. }
|
||||
| ParseNode::TextOrd { text, mode, .. }
|
||||
| ParseNode::OpToken { text, mode, .. } => {
|
||||
let t = symbol(text, *mode).ok_or_else(|| unsupported(text))?;
|
||||
self.run(&t, st);
|
||||
}
|
||||
ParseNode::SpacingNode { text, .. } => {
|
||||
let t = match text.as_str() {
|
||||
// A literal space in text mode (`\text{if } x`) is a spacing node of " ".
|
||||
" " | "\\ " | "~" | "\\space" | "\\nobreakspace" => " ",
|
||||
"\\quad" => "\u{2003}",
|
||||
"\\qquad" => "\u{2003}\u{2003}",
|
||||
"\\," | "\\thinspace" => "\u{2009}",
|
||||
"\\:" | "\\>" | "\\medspace" => "\u{205F}",
|
||||
"\\;" | "\\thickspace" => "\u{2004}",
|
||||
_ => "",
|
||||
};
|
||||
self.run(t, st);
|
||||
}
|
||||
ParseNode::Kern { .. } => {}
|
||||
ParseNode::OrdGroup { body, .. }
|
||||
| ParseNode::Styling { body, .. }
|
||||
| ParseNode::Sizing { body, .. }
|
||||
| ParseNode::MClass { body, .. }
|
||||
| ParseNode::Href { body, .. }
|
||||
| ParseNode::HBox { body, .. }
|
||||
| ParseNode::Pmb { body, .. } => self.seq(body, st, depth)?,
|
||||
ParseNode::Color { body, .. } => self.seq(body, st, depth)?,
|
||||
ParseNode::Text { body, font, .. } => {
|
||||
let mut s = Style { normal: true, ..st.clone() };
|
||||
if let Some(f) = font {
|
||||
s.sty = match f.as_str() {
|
||||
"\\textbf" => Some("b"),
|
||||
"\\textit" => Some("i"),
|
||||
_ => s.sty,
|
||||
};
|
||||
}
|
||||
self.seq(body, &s, depth)?;
|
||||
}
|
||||
ParseNode::Font { font, body, .. } => {
|
||||
let mut s = st.clone();
|
||||
match font.trim_start_matches('\\') {
|
||||
"mathbb" | "Bbb" => s.scr = Some("double-struck"),
|
||||
"mathcal" | "mathscr" => s.scr = Some("script"),
|
||||
"mathfrak" | "frak" => s.scr = Some("fraktur"),
|
||||
"mathsf" | "textsf" => s.scr = Some("sans-serif"),
|
||||
"mathtt" | "texttt" => s.scr = Some("monospace"),
|
||||
"mathbf" | "bf" | "textbf" => s.sty = Some("b"),
|
||||
"boldsymbol" | "bm" => s.sty = Some("bi"),
|
||||
"mathrm" | "rm" | "textrm" | "mathnormal" => s.sty = Some("p"),
|
||||
"mathit" | "it" | "textit" => s.sty = Some("i"),
|
||||
other => return Err(unsupported(&format!("\\{other}"))),
|
||||
}
|
||||
self.node(body, &s, depth + 1)?;
|
||||
}
|
||||
ParseNode::SupSub { base, sup, sub, .. } => {
|
||||
// A function with limits under it (\lim_{x\to 0}, \max_i).
|
||||
if let Some(b) = base.as_deref()
|
||||
&& let ParseNode::Op { limits: true, symbol: false, .. } | ParseNode::OperatorName { limits: true, .. } = b
|
||||
&& sup.is_none()
|
||||
{
|
||||
self.out.push_str("<m:limLow><m:e>");
|
||||
self.node(b, st, depth + 1)?;
|
||||
self.out.push_str("</m:e>");
|
||||
self.wrap1("m:lim", sub.as_deref(), st, depth + 1)?;
|
||||
self.out.push_str("</m:limLow>");
|
||||
return Ok(());
|
||||
}
|
||||
// A brace with a label over or under it (`\overbrace{x}^{n}`, `\underbrace{x}_{n}`):
|
||||
// the label is the limit on the brace's side. A script on the other side
|
||||
// (`\underbrace{x}_{n}^{2}`) is a script around the whole, `m:sSup` or `m:sSub`,
|
||||
// so that it reaches Office Math too; without a label it goes around the bare
|
||||
// brace, with no empty limit.
|
||||
if let Some(b @ ParseNode::HorizBrace { is_over, .. }) = base.as_deref() {
|
||||
let (lim_tag, label, (script_tag, script_arg), script) = if *is_over {
|
||||
("m:limUpp", sup.as_deref(), ("m:sSub", "m:sub"), sub.as_deref())
|
||||
} else {
|
||||
("m:limLow", sub.as_deref(), ("m:sSup", "m:sup"), sup.as_deref())
|
||||
};
|
||||
// Each wrapper around the brace is a level of its own.
|
||||
let inner = if script.is_some() { depth + 1 } else { depth };
|
||||
if script.is_some() {
|
||||
self.out.push_str(&format!("<{script_tag}><m:e>"));
|
||||
}
|
||||
if label.is_some() {
|
||||
self.out.push_str(&format!("<{lim_tag}><m:e>"));
|
||||
self.node(b, st, inner + 1)?;
|
||||
self.out.push_str("</m:e>");
|
||||
self.wrap1("m:lim", label, st, inner + 1)?;
|
||||
self.out.push_str(&format!("</{lim_tag}>"));
|
||||
} else {
|
||||
self.node(b, st, inner)?;
|
||||
}
|
||||
if script.is_some() {
|
||||
self.out.push_str("</m:e>");
|
||||
self.wrap1(script_arg, script, st, depth + 1)?;
|
||||
self.out.push_str(&format!("</{script_tag}>"));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let tag = match (sub.is_some(), sup.is_some()) {
|
||||
(true, true) => "m:sSubSup",
|
||||
(true, false) => "m:sSub",
|
||||
_ => "m:sSup",
|
||||
};
|
||||
self.out.push_str(&format!("<{tag}>"));
|
||||
self.wrap1("m:e", base.as_deref(), st, depth + 1)?;
|
||||
if sub.is_some() {
|
||||
self.wrap1("m:sub", sub.as_deref(), st, depth + 1)?;
|
||||
}
|
||||
if sup.is_some() || sub.is_none() {
|
||||
self.wrap1("m:sup", sup.as_deref(), st, depth + 1)?;
|
||||
}
|
||||
self.out.push_str(&format!("</{tag}>"));
|
||||
}
|
||||
ParseNode::GenFrac { numer, denom, has_bar_line, left_delim, right_delim, .. } => {
|
||||
let delims = left_delim.is_some() || right_delim.is_some();
|
||||
if delims {
|
||||
self.out.push_str(&format!(
|
||||
"<m:d><m:dPr><m:begChr m:val=\"{}\"/><m:endChr m:val=\"{}\"/></m:dPr><m:e>",
|
||||
esc(&delim(left_delim.as_deref().unwrap_or("."))?),
|
||||
esc(&delim(right_delim.as_deref().unwrap_or("."))?)
|
||||
));
|
||||
}
|
||||
self.out.push_str("<m:f>");
|
||||
if !has_bar_line {
|
||||
self.out.push_str("<m:fPr><m:type m:val=\"noBar\"/></m:fPr>");
|
||||
}
|
||||
self.wrap1("m:num", Some(numer), st, depth + 1)?;
|
||||
self.wrap1("m:den", Some(denom), st, depth + 1)?;
|
||||
self.out.push_str("</m:f>");
|
||||
if delims {
|
||||
self.out.push_str("</m:e></m:d>");
|
||||
}
|
||||
}
|
||||
ParseNode::Sqrt { body, index, .. } => {
|
||||
self.out.push_str("<m:rad>");
|
||||
match index {
|
||||
Some(ix) => {
|
||||
self.wrap1("m:deg", Some(ix), st, depth + 1)?;
|
||||
}
|
||||
None => self.out.push_str("<m:radPr><m:degHide m:val=\"1\"/></m:radPr><m:deg/>"),
|
||||
}
|
||||
self.wrap1("m:e", Some(body), st, depth + 1)?;
|
||||
self.out.push_str("</m:rad>");
|
||||
}
|
||||
ParseNode::Op { name: Some(name), symbol: false, .. } => {
|
||||
let t = name.trim_start_matches('\\');
|
||||
self.run(t, &Style { sty: Some("p"), ..st.clone() });
|
||||
}
|
||||
ParseNode::Op { name: Some(name), .. } => {
|
||||
let t = symbol(name, Mode::Math).ok_or_else(|| unsupported(name))?;
|
||||
self.run(&t, st);
|
||||
}
|
||||
ParseNode::Op { body: Some(body), .. } => self.seq(body, st, depth)?,
|
||||
ParseNode::OperatorName { body, .. } => self.seq(body, &Style { sty: Some("p"), ..st.clone() }, depth)?,
|
||||
ParseNode::LeftRight { body, left, right, .. } => {
|
||||
self.out.push_str(&format!(
|
||||
"<m:d><m:dPr><m:begChr m:val=\"{}\"/><m:endChr m:val=\"{}\"/></m:dPr>",
|
||||
esc(&delim(left)?),
|
||||
esc(&delim(right)?)
|
||||
));
|
||||
// \middle splits the contents into separated elements.
|
||||
let mut parts: Vec<&[ParseNode]> = Vec::new();
|
||||
let mut start = 0;
|
||||
for (k, b) in body.iter().enumerate() {
|
||||
if matches!(b, ParseNode::Middle { .. }) {
|
||||
parts.push(body.get(start..k).unwrap_or_default());
|
||||
start = k + 1;
|
||||
}
|
||||
}
|
||||
parts.push(body.get(start..).unwrap_or_default());
|
||||
if parts.len() > 1 {
|
||||
let sep =
|
||||
body.iter().find_map(|b| if let ParseNode::Middle { delim: d, .. } = b { delim(d).ok() } else { None }).unwrap_or_default();
|
||||
// Rewrite the dPr with the separator: drop what was pushed and start again.
|
||||
let cut = self.out.rfind("<m:d>").unwrap_or(self.out.len());
|
||||
self.out.truncate(cut);
|
||||
self.out.push_str(&format!(
|
||||
"<m:d><m:dPr><m:begChr m:val=\"{}\"/><m:sepChr m:val=\"{}\"/><m:endChr m:val=\"{}\"/></m:dPr>",
|
||||
esc(&delim(left)?),
|
||||
esc(&sep),
|
||||
esc(&delim(right)?)
|
||||
));
|
||||
}
|
||||
for p in parts {
|
||||
self.wrap("m:e", p, st, depth + 1)?;
|
||||
}
|
||||
self.out.push_str("</m:d>");
|
||||
}
|
||||
ParseNode::DelimSizing { delim: d, .. } => {
|
||||
let t = delim(d)?;
|
||||
self.run(&t, st);
|
||||
}
|
||||
ParseNode::Array { body, is_cd: None | Some(false), .. } => {
|
||||
let cols = body.iter().map(Vec::len).max().unwrap_or(0).max(1);
|
||||
self.out.push_str(&format!(
|
||||
"<m:m><m:mPr><m:mcs><m:mc><m:mcPr><m:count m:val=\"{cols}\"/><m:mcJc m:val=\"center\"/></m:mcPr></m:mc></m:mcs></m:mPr>"
|
||||
));
|
||||
// Every row the parser gives is written: RaTeX itself drops the empty last row a
|
||||
// trailing `\\` leaves, and an empty row anywhere else is the author's blank line.
|
||||
for row in body {
|
||||
self.out.push_str("<m:mr>");
|
||||
for k in 0..cols {
|
||||
self.wrap1("m:e", row.get(k), st, depth + 1)?;
|
||||
}
|
||||
self.out.push_str("</m:mr>");
|
||||
}
|
||||
self.out.push_str("</m:m>");
|
||||
}
|
||||
ParseNode::Accent { label, base, .. } => {
|
||||
let chr = match label.as_str() {
|
||||
"\\hat" | "\\widehat" => "\u{0302}",
|
||||
"\\check" | "\\widecheck" => "\u{030C}",
|
||||
"\\tilde" | "\\widetilde" => "\u{0303}",
|
||||
"\\acute" => "\u{0301}",
|
||||
"\\grave" => "\u{0300}",
|
||||
"\\dot" => "\u{0307}",
|
||||
"\\ddot" => "\u{0308}",
|
||||
"\\breve" => "\u{0306}",
|
||||
"\\bar" => "\u{0305}",
|
||||
"\\vec" | "\\overrightarrow" => "\u{20D7}",
|
||||
"\\overleftarrow" => "\u{20D6}",
|
||||
"\\mathring" => "\u{030A}",
|
||||
other => return Err(unsupported(other)),
|
||||
};
|
||||
self.out.push_str(&format!("<m:acc><m:accPr><m:chr m:val=\"{chr}\"/></m:accPr>"));
|
||||
self.wrap1("m:e", Some(base), st, depth + 1)?;
|
||||
self.out.push_str("</m:acc>");
|
||||
}
|
||||
ParseNode::Overline { body, .. } | ParseNode::Underline { body, .. } => {
|
||||
let pos = if matches!(n, ParseNode::Overline { .. }) { "top" } else { "bot" };
|
||||
self.out.push_str(&format!("<m:bar><m:barPr><m:pos m:val=\"{pos}\"/></m:barPr>"));
|
||||
self.wrap1("m:e", Some(body), st, depth + 1)?;
|
||||
self.out.push_str("</m:bar>");
|
||||
}
|
||||
ParseNode::HorizBrace { is_over, base, .. } => {
|
||||
let (chr, pos, vert) = if *is_over { ("\u{23DE}", "top", "bot") } else { ("\u{23DF}", "bot", "top") };
|
||||
self.out.push_str(&format!(
|
||||
"<m:groupChr><m:groupChrPr><m:chr m:val=\"{chr}\"/><m:pos m:val=\"{pos}\"/><m:vertJc m:val=\"{vert}\"/></m:groupChrPr>"
|
||||
));
|
||||
self.wrap1("m:e", Some(base), st, depth + 1)?;
|
||||
self.out.push_str("</m:groupChr>");
|
||||
}
|
||||
ParseNode::Enclose { label, body, .. } if label == "\\boxed" || label == "\\fbox" => {
|
||||
self.out.push_str("<m:borderBox>");
|
||||
self.wrap1("m:e", Some(body), st, depth + 1)?;
|
||||
self.out.push_str("</m:borderBox>");
|
||||
}
|
||||
ParseNode::Phantom { body, .. } => {
|
||||
self.out.push_str("<m:phant><m:phantPr><m:show m:val=\"0\"/></m:phantPr>");
|
||||
self.wrap("m:e", body, st, depth + 1)?;
|
||||
self.out.push_str("</m:phant>");
|
||||
}
|
||||
ParseNode::Internal { .. } | ParseNode::NoNumber { .. } | ParseNode::ColorToken { .. } => {}
|
||||
other => return Err(unsupported(&format!("\"{}\"", node_name(other)))),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A short name for an unsupported construct, for the error.
|
||||
fn node_name(n: &ParseNode) -> String {
|
||||
let dbg = format!("{n:?}");
|
||||
dbg.split([' ', '{', '(']).next().unwrap_or("This construct").to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn om(s: &str) -> String {
|
||||
to_omml(s, 11.0, None).unwrap()
|
||||
}
|
||||
|
||||
/// The markup without the per-run DrawingML properties, to keep assertions readable.
|
||||
fn bare(s: &str) -> String {
|
||||
let mut o = om(s);
|
||||
while let Some(a) = o.find("<a:rPr") {
|
||||
let Some(b) = o[a..].find("</a:rPr>") else { break };
|
||||
o.replace_range(a..a + b + "</a:rPr>".len(), "");
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_fractions_and_roots() {
|
||||
assert_eq!(bare("x+1"), "<m:oMath><m:r><m:t>x</m:t></m:r><m:r><m:t>+</m:t></m:r><m:r><m:t>1</m:t></m:r></m:oMath>");
|
||||
assert!(om("x").contains("sz=\"1100\""));
|
||||
assert_eq!(
|
||||
bare("\\frac{a}{b}"),
|
||||
"<m:oMath><m:f><m:num><m:r><m:t>a</m:t></m:r></m:num><m:den><m:r><m:t>b</m:t></m:r></m:den></m:f></m:oMath>"
|
||||
);
|
||||
assert!(
|
||||
bare("\\binom{n}{k}")
|
||||
.contains("<m:d><m:dPr><m:begChr m:val=\"(\"/><m:endChr m:val=\")\"/></m:dPr><m:e><m:f><m:fPr><m:type m:val=\"noBar\"/>")
|
||||
);
|
||||
assert!(bare("\\sqrt{x}").starts_with("<m:oMath><m:rad><m:radPr><m:degHide m:val=\"1\"/></m:radPr><m:deg/><m:e>"));
|
||||
assert!(bare("\\sqrt[3]{x}").contains("<m:deg><m:r><m:t>3</m:t></m:r></m:deg>"));
|
||||
assert!(bare("\\alpha \\le \\infty").contains("<m:t>α</m:t></m:r><m:r><m:t>≤</m:t></m:r><m:r><m:t>∞</m:t>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scripts_operators_and_limits() {
|
||||
assert_eq!(bare("x^2"), "<m:oMath><m:sSup><m:e><m:r><m:t>x</m:t></m:r></m:e><m:sup><m:r><m:t>2</m:t></m:r></m:sup></m:sSup></m:oMath>");
|
||||
assert!(bare("a_{i}^{2}").starts_with("<m:oMath><m:sSubSup><m:e>"));
|
||||
let s = bare("\\sum_{k=0}^{n} k = m");
|
||||
assert!(s.starts_with("<m:oMath><m:nary><m:naryPr><m:chr m:val=\"∑\"/><m:limLoc m:val=\"undOvr\"/></m:naryPr><m:sub>"), "{s}");
|
||||
assert!(s.contains("<m:e><m:r><m:t>k</m:t></m:r></m:e></m:nary><m:r><m:t>=</m:t>"), "the operand stops at the relation: {s}");
|
||||
assert!(bare("\\int x").contains("<m:subHide m:val=\"1\"/><m:supHide m:val=\"1\"/>"));
|
||||
let l = bare("\\lim_{n\\to\\infty} a");
|
||||
assert!(l.starts_with("<m:oMath><m:limLow><m:e><m:r><m:rPr><m:sty m:val=\"p\"/></m:rPr><m:t>lim</m:t>"), "{l}");
|
||||
assert!(bare("\\sin x").contains("<m:sty m:val=\"p\"/></m:rPr><m:t>sin</m:t>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delimiters_matrices_accents_and_fonts() {
|
||||
let p = bare("\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}");
|
||||
assert!(p.contains("<m:d><m:dPr><m:begChr m:val=\"(\"/><m:endChr m:val=\")\"/></m:dPr><m:e><m:m>"), "{p}");
|
||||
assert_eq!(p.matches("<m:mr>").count(), 2);
|
||||
assert_eq!(p.matches("<m:e>").count(), 5);
|
||||
assert!(bare("\\left\\{ x \\right.").contains("<m:begChr m:val=\"{\"/><m:endChr m:val=\"\"/>"));
|
||||
assert!(bare("\\left\\langle a \\middle| b \\right\\rangle").contains("<m:sepChr m:val=\"|\"/>"));
|
||||
assert!(bare("\\hat{x}").contains("<m:acc><m:accPr><m:chr m:val=\"\u{0302}\"/>"));
|
||||
assert!(bare("\\overline{z}").contains("<m:bar><m:barPr><m:pos m:val=\"top\"/>"));
|
||||
assert!(bare("\\mathbb{R}").contains("<m:rPr><m:scr m:val=\"double-struck\"/></m:rPr>"));
|
||||
assert!(bare("\\text{if } x").contains("<m:rPr><m:nor/></m:rPr>"));
|
||||
assert!(bare("\\overbrace{a+b}^{n}").contains("<m:limUpp><m:e><m:groupChr>"));
|
||||
assert!(bare("a < b \\& c").contains("<m:t><</m:t>") && bare("a \\& b").contains("<m:t>&</m:t>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_underbrace_with_both_scripts_keeps_its_exponent() {
|
||||
// The label under the brace is its limit; the exponent on the other side is a
|
||||
// superscript around the whole, so that an application reading the Office Math sees it
|
||||
// (the source in `descr` hid its loss in a GridCraft-to-GridCraft round trip).
|
||||
let brace = |chr: &str, pos: &str, vert: &str| {
|
||||
format!(
|
||||
"<m:groupChr><m:groupChrPr><m:chr m:val=\"{chr}\"/><m:pos m:val=\"{pos}\"/><m:vertJc m:val=\"{vert}\"/></m:groupChrPr><m:e><m:r><m:t>x</m:t></m:r></m:e></m:groupChr>"
|
||||
)
|
||||
};
|
||||
let under = brace("\u{23DF}", "bot", "top");
|
||||
let over = brace("\u{23DE}", "top", "bot");
|
||||
assert_eq!(
|
||||
bare("\\underbrace{x}_{n}^{2}"),
|
||||
format!(
|
||||
"<m:oMath><m:sSup><m:e><m:limLow><m:e>{under}</m:e><m:lim><m:r><m:t>n</m:t></m:r></m:lim></m:limLow></m:e><m:sup><m:r><m:t>2</m:t></m:r></m:sup></m:sSup></m:oMath>"
|
||||
)
|
||||
);
|
||||
// The mirror: the label over the brace, the subscript around the whole.
|
||||
assert_eq!(
|
||||
bare("\\overbrace{x}^{n}_{m}"),
|
||||
format!(
|
||||
"<m:oMath><m:sSub><m:e><m:limUpp><m:e>{over}</m:e><m:lim><m:r><m:t>n</m:t></m:r></m:lim></m:limUpp></m:e><m:sub><m:r><m:t>m</m:t></m:r></m:sub></m:sSub></m:oMath>"
|
||||
)
|
||||
);
|
||||
// Nested braces keep every script, one wrapper per level.
|
||||
let nested = bare("\\underbrace{\\underbrace{x}_{a}^{b}}_{c}^{d}");
|
||||
assert_eq!(nested.matches("<m:limLow>").count(), 2, "{nested}");
|
||||
assert_eq!(nested.matches("<m:sup>").count(), 2, "{nested}");
|
||||
for t in ["a", "b", "c", "d"] {
|
||||
assert!(nested.contains(&format!("<m:t>{t}</m:t>")), "{nested}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_underbrace_without_a_label_keeps_its_superscript() {
|
||||
// No label: the script goes around the bare brace, and there is no empty limit.
|
||||
let under = bare("\\underbrace{x}^{2}");
|
||||
assert!(under.starts_with("<m:oMath><m:sSup><m:e><m:groupChr>"), "{under}");
|
||||
assert!(under.ends_with("</m:groupChr></m:e><m:sup><m:r><m:t>2</m:t></m:r></m:sup></m:sSup></m:oMath>"), "{under}");
|
||||
assert!(!under.contains("<m:lim"), "{under}");
|
||||
let over = bare("\\overbrace{x}_{m}");
|
||||
assert!(over.starts_with("<m:oMath><m:sSub><m:e><m:groupChr>"), "{over}");
|
||||
assert!(over.ends_with("</m:groupChr></m:e><m:sub><m:r><m:t>m</m:t></m:r></m:sub></m:sSub></m:oMath>"), "{over}");
|
||||
assert!(!over.contains("<m:lim"), "{over}");
|
||||
// A brace with neither script, and one with its label alone, are as they were.
|
||||
let plain = bare("\\underbrace{x}");
|
||||
assert!(plain.starts_with("<m:oMath><m:groupChr>") && !plain.contains("<m:sSup>") && !plain.contains("<m:lim"), "{plain}");
|
||||
let labelled = bare("\\underbrace{x}_{n}");
|
||||
assert!(labelled.starts_with("<m:oMath><m:limLow><m:e><m:groupChr>") && !labelled.contains("<m:sSup>"), "{labelled}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_unsupported_subset_is_an_error() {
|
||||
for s in ["\\begin{CD} A @>>> B \\end{CD}", "\\rule{1em}{1em}", "\\xrightarrow{f}", "\\verb|x|"] {
|
||||
assert!(to_omml(s, 11.0, None).is_err(), "{s}");
|
||||
}
|
||||
assert!(to_omml("\\frac{1}{", 11.0, None).is_err());
|
||||
assert!(to_omml(&"x".repeat(5000), 11.0, None).is_err());
|
||||
assert!(om("x").starts_with("<m:oMath>"));
|
||||
assert!(to_omml("x", f32::NAN, None).unwrap().contains("sz=\"1100\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_literal_space_in_text_mode_is_kept() {
|
||||
// RaTeX gives a text-mode space as a spacing node whose text is " "; it is a run of its
|
||||
// own, marked `xml:space="preserve"` so that Word keeps it, and `\text{if } x` does not
|
||||
// come out as `ifx`.
|
||||
let o = bare("\\text{a b}");
|
||||
assert!(o.contains("<m:t xml:space=\"preserve\"> </m:t>"), "{o}");
|
||||
let o = bare("\\text{if } x");
|
||||
assert!(o.contains("<m:t xml:space=\"preserve\"> </m:t></m:r><m:r><m:t>x</m:t>"), "{o}");
|
||||
assert!(!bare("x+1").contains("xml:space"), "runs without surrounding whitespace are not marked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_carry_the_ink_colour_when_one_is_given() {
|
||||
let o = to_omml("x+1", 11.0, Some([0x1F, 0x4E, 0x79])).unwrap();
|
||||
let run =
|
||||
"<a:rPr lang=\"en-US\" sz=\"1100\"><a:solidFill><a:srgbClr val=\"1F4E79\"/></a:solidFill><a:latin typeface=\"Cambria Math\"/></a:rPr>";
|
||||
assert_eq!(o.matches(run).count(), 3, "{o}");
|
||||
assert!(!om("x+1").contains("<a:solidFill>"), "no ink: the paragraph's colour applies");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn characters_xml_cannot_carry_are_dropped_from_runs() {
|
||||
// U+FFFE and U+FFFF are not XML characters, nor are C0 controls other than tab, newline
|
||||
// and carriage return; the xlsx escapes drop them and this writer does the same, so the
|
||||
// OMML it hands the drawing part is well formed. U+FDD0 is a Unicode noncharacter but an
|
||||
// XML character, kept as the xlsx escapes keep it.
|
||||
assert_eq!(esc("a\u{FFFE}b\u{FFFF}c\u{1}d\u{B}e\u{7F}f"), "abcde\u{7F}f");
|
||||
assert_eq!(esc("a\tb\nc\rd\u{FDD0}e"), "a\tb\nc d\u{FDD0}e");
|
||||
for (src, bad) in [("x\u{FFFE}y", '\u{FFFE}'), ("x\u{FFFF}y", '\u{FFFF}'), ("\\text{a\u{FFFF}b}", '\u{FFFF}')] {
|
||||
if let Ok(o) = to_omml(src, 11.0, None) {
|
||||
assert!(!o.contains(bad), "{src:?}: {o}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_trailing_empty_row_is_dropped() {
|
||||
// RaTeX drops the empty last row a trailing `\\` leaves (its `environments.rs`), and the
|
||||
// writer keeps every row it is given, so a blank row in the middle, written as `\\ \\` or
|
||||
// as `{}`, reaches Office as the sheet shows it. A RaTeX release that stopped dropping the
|
||||
// trailing row would show up here as a third row.
|
||||
assert_eq!(bare("\\begin{matrix} a \\\\ b \\\\ \\end{matrix}").matches("<m:mr>").count(), 2);
|
||||
assert_eq!(bare("\\begin{cases} x \\\\ \\\\ y \\end{cases}").matches("<m:mr>").count(), 3);
|
||||
assert_eq!(bare("\\begin{matrix} a \\\\ {} \\\\ b \\end{matrix}").matches("<m:mr>").count(), 3);
|
||||
assert_eq!(bare("\\begin{matrix} \\\\ \\end{matrix}").matches("<m:mr>").count(), 1, "one blank row is still a row");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_gallery_example_converts() {
|
||||
for s in [
|
||||
"x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}",
|
||||
"a^2 + b^2 = c^2",
|
||||
"(x + a)^n = \\sum_{k=0}^{n} \\binom{n}{k} x^k a^{n-k}",
|
||||
"A = \\pi r^2",
|
||||
"e^{i\\pi} + 1 = 0",
|
||||
"\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}",
|
||||
"f(x) = \\begin{cases} x & x \\ge 0 \\\\ -x & x < 0 \\end{cases}",
|
||||
] {
|
||||
assert!(to_omml(s, 11.0, None).is_ok(), "{s}: {:?}", to_omml(s, 11.0, None));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
//! The RaTeX gate, kept as a regression suite so that a version bump re-runs it: hostile and
|
||||
//! random input never panics, nesting is bounded below the stack, macro bombs stop, and the
|
||||
//! syntax an equation editor needs is covered. Every call goes through `catch_unwind`, so a panic
|
||||
//! is counted, not fatal. `docs/decisions.md` holds the full gate record.
|
||||
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
enum Outcome {
|
||||
Laid(usize),
|
||||
Rejected(String),
|
||||
Panicked(String),
|
||||
}
|
||||
|
||||
fn run(src: &str) -> Outcome {
|
||||
let r = catch_unwind(AssertUnwindSafe(|| match ratex_parser::parse(src) {
|
||||
Ok(nodes) => {
|
||||
let lb = ratex_layout::layout(&nodes, &ratex_layout::LayoutOptions::default());
|
||||
Ok(ratex_layout::to_display_list(&lb).items.len())
|
||||
}
|
||||
Err(e) => Err(e.message),
|
||||
}));
|
||||
match r {
|
||||
Ok(Ok(n)) => Outcome::Laid(n),
|
||||
Ok(Err(m)) => Outcome::Rejected(m),
|
||||
Err(p) => {
|
||||
Outcome::Panicked(p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// xorshift64: deterministic, so a failure reproduces.
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
self.0
|
||||
}
|
||||
fn below(&mut self, n: usize) -> usize {
|
||||
(self.next() % n.max(1) as u64) as usize
|
||||
}
|
||||
fn pick<'a>(&mut self, v: &[&'a str]) -> &'a str {
|
||||
v[self.below(v.len())]
|
||||
}
|
||||
}
|
||||
|
||||
const TOKENS: &[&str] = &[
|
||||
"{",
|
||||
"}",
|
||||
"[",
|
||||
"]",
|
||||
"(",
|
||||
")",
|
||||
"^",
|
||||
"_",
|
||||
"&",
|
||||
"\\\\",
|
||||
"x",
|
||||
"1",
|
||||
"+",
|
||||
"=",
|
||||
"\\frac",
|
||||
"\\sqrt",
|
||||
"\\left",
|
||||
"\\right",
|
||||
"\\middle",
|
||||
"\\sum",
|
||||
"\\int",
|
||||
"\\begin{matrix}",
|
||||
"\\end{matrix}",
|
||||
"\\begin{pmatrix}",
|
||||
"\\end{pmatrix}",
|
||||
"\\begin{cases}",
|
||||
"\\end{cases}",
|
||||
"\\begin{aligned}",
|
||||
"\\end{aligned}",
|
||||
"\\begin{array}{cc}",
|
||||
"\\end{array}",
|
||||
"\\text{",
|
||||
"\\mathbb",
|
||||
"\\hat",
|
||||
"\\overline",
|
||||
"\\underbrace",
|
||||
"\\overbrace",
|
||||
"\\binom",
|
||||
"\\color{red}",
|
||||
"\\def\\a{\\a}",
|
||||
"\\a",
|
||||
"\\newcommand",
|
||||
"\\boxed",
|
||||
"\\xrightarrow",
|
||||
"\\stackrel",
|
||||
"\\over",
|
||||
"\\atop",
|
||||
"\\choose",
|
||||
"\\displaystyle",
|
||||
"\\big",
|
||||
"\\Bigg",
|
||||
"|",
|
||||
"\\|",
|
||||
"\\langle",
|
||||
".",
|
||||
"\\ce{",
|
||||
"\\pu{",
|
||||
"H2O",
|
||||
"->",
|
||||
"\\operatorname",
|
||||
"\\mathrm",
|
||||
"\\verb|x|",
|
||||
"\\char",
|
||||
"\\kern",
|
||||
"\\hspace{",
|
||||
"1em",
|
||||
"-1000em",
|
||||
"\\rule{",
|
||||
"\\raisebox{",
|
||||
"\\phantom",
|
||||
"\\cancel",
|
||||
"\\not",
|
||||
"\\limits",
|
||||
"\\tag{",
|
||||
"$",
|
||||
"%",
|
||||
"#",
|
||||
"~",
|
||||
"'",
|
||||
"\\vec",
|
||||
"\\mathchoice",
|
||||
"\\href{",
|
||||
"\\includegraphics",
|
||||
"\\cr",
|
||||
"\\hline",
|
||||
"\\multicolumn",
|
||||
"\\begin{CD}",
|
||||
"\\end{CD}",
|
||||
"@>>>",
|
||||
"\\infty",
|
||||
"\\alpha",
|
||||
"\\partial",
|
||||
"\u{1F600}",
|
||||
"\u{0301}",
|
||||
"\u{0000}",
|
||||
"\u{FFFF}",
|
||||
"\\sideset",
|
||||
"\\genfrac",
|
||||
"\\substack{",
|
||||
"\\smash",
|
||||
"\\llap",
|
||||
"\\textcolor{",
|
||||
"#ff0000",
|
||||
"\\colorbox{",
|
||||
"\\sqrt[",
|
||||
"\\lim",
|
||||
"\\overset",
|
||||
"\\underset",
|
||||
"\\Huge",
|
||||
"\\begin{align}",
|
||||
"\\end{align}",
|
||||
"\\begin{alignat}{2}",
|
||||
"\\end{alignat}",
|
||||
"\\begin{rcases}",
|
||||
"\\expandafter",
|
||||
"\\noexpand",
|
||||
"\\let",
|
||||
"\\gdef",
|
||||
"\\global",
|
||||
"\\edef",
|
||||
"\\boldsymbol",
|
||||
"\\mathcal",
|
||||
"\\mathfrak",
|
||||
"\\char\"41",
|
||||
"\\unicode",
|
||||
"\\infer",
|
||||
];
|
||||
|
||||
/// What the editor's gallery and quick-insert buttons produce, plus everyday notation.
|
||||
const COVERAGE: &[&str] = &[
|
||||
"x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}",
|
||||
"a^2 + b^2 = c^2",
|
||||
"(x + a)^n = \\sum_{k=0}^{n} \\binom{n}{k} x^k a^{n-k}",
|
||||
"A = \\pi r^2",
|
||||
"e^{i\\pi} + 1 = 0",
|
||||
"\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}",
|
||||
"\\begin{bmatrix}1&0\\\\0&1\\end{bmatrix}",
|
||||
"\\begin{vmatrix}a&b\\\\c&d\\end{vmatrix}",
|
||||
"\\int_0^\\infty e^{-x^2}\\,dx",
|
||||
"\\iint \\oint \\prod_{i} \\coprod",
|
||||
"\\lim_{n\\to\\infty}\\left(1+\\frac1n\\right)^n",
|
||||
"f(x) = \\begin{cases} x & x \\ge 0 \\\\ -x & x < 0 \\end{cases}",
|
||||
"\\begin{aligned} a &= b \\\\ c &= d \\end{aligned}",
|
||||
"\\overbrace{a+b}^{n} \\underbrace{c}_{m}",
|
||||
"\\vec v \\cdot \\hat n \\times \\overline{z} \\tilde a \\dot x \\ddot y \\bar b",
|
||||
"\\mathbb{R}^n \\mathcal{L} \\mathfrak g \\mathrm{d} \\mathbf{v} \\mathit{x} \\mathsf{s} \\mathtt{t}",
|
||||
"\\sqrt[3]{x}",
|
||||
"\\left\\langle \\psi \\middle| \\phi \\right\\rangle",
|
||||
"\\left\\{ x \\right\\} \\left[ y \\right] \\left| z \\right| \\left\\lfloor w \\right\\rceil",
|
||||
"\\color{red}{x}\\textcolor{blue}{y}",
|
||||
"\\text{if } x \\in A",
|
||||
"\\xrightarrow[below]{above} \\xleftarrow{a}",
|
||||
"\\begin{array}{c|c} 1 & 2 \\\\ \\hline 3 & 4 \\end{array}",
|
||||
"\\alpha\\beta\\gamma\\delta\\epsilon\\varepsilon\\zeta\\eta\\theta\\vartheta\\iota\\kappa\\lambda\\mu\\nu\\xi\\pi\\rho\\sigma\\tau\\upsilon\\phi\\varphi\\chi\\psi\\omega",
|
||||
"\\Gamma\\Delta\\Theta\\Lambda\\Xi\\Pi\\Sigma\\Upsilon\\Phi\\Psi\\Omega",
|
||||
"\\le\\ge\\ne\\approx\\equiv\\sim\\simeq\\cong\\propto\\ll\\gg\\subset\\supseteq\\in\\notin\\ni",
|
||||
"\\pm\\mp\\times\\div\\cdot\\circ\\bullet\\oplus\\otimes\\cup\\cap\\wedge\\vee\\setminus",
|
||||
"\\infty\\partial\\nabla\\forall\\exists\\emptyset\\angle\\triangle\\hbar\\ell\\Re\\Im\\aleph",
|
||||
"\\to\\leftarrow\\Rightarrow\\Leftrightarrow\\mapsto\\uparrow\\iff\\implies",
|
||||
"\\sin\\cos\\tan\\log\\ln\\exp\\max\\min\\det\\gcd\\operatorname{sgn}",
|
||||
"\\displaystyle\\sum_{i=1}^n i = \\frac{n(n+1)}{2}",
|
||||
"\\binom{n}{k} \\dbinom{a}{b} \\tfrac12 \\dfrac{1}{2} \\cfrac{1}{1+\\cfrac{1}{2}}",
|
||||
"x_{i,j}^{(k)} x' x''",
|
||||
"\\boxed{E=mc^2}",
|
||||
"\\cancel{x} \\bcancel{y}",
|
||||
"\\overset{!}{=} \\underset{x}{\\arg\\max} \\stackrel{def}{=}",
|
||||
"\\begin{matrix}1&2\\\\3&4\\end{matrix}",
|
||||
"\\begin{smallmatrix}1&2\\end{smallmatrix}",
|
||||
"\\begin{gather} a \\\\ b \\end{gather}",
|
||||
"\\begin{align} a &= b \\end{align}",
|
||||
"\\ldots\\cdots\\vdots\\ddots",
|
||||
"a\\quad b\\qquad c\\,d\\;e\\!f",
|
||||
"\\langle x \\rangle \\lVert v \\rVert",
|
||||
"\\frac{\\partial f}{\\partial x}",
|
||||
"\\int\\limits_a^b",
|
||||
"a\\phantom{x}b\\hphantom{y}c",
|
||||
"\\widehat{abc} \\widetilde{xyz} \\overrightarrow{AB}",
|
||||
"a \\pmod{n} \\bmod b",
|
||||
"\\Big( \\bigg[ \\Bigg\\{",
|
||||
"\\not= \\not\\in",
|
||||
"1{,}000 \\%\\$\\#\\&\\_\\{\\}",
|
||||
"\\textbf{bold} \\textit{it}",
|
||||
"\\sum_{\\substack{a\\\\b}}",
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn random_token_soup_never_panics() {
|
||||
let mut rng = Rng(0x9e37_79b9_7f4a_7c15);
|
||||
let mut panics = Vec::new();
|
||||
for _ in 0..3000 {
|
||||
let mut s = String::new();
|
||||
for _ in 0..1 + rng.below(24) {
|
||||
if rng.below(10) == 0 {
|
||||
s.push(char::from_u32(rng.below(0x3000) as u32).unwrap_or('?'));
|
||||
} else {
|
||||
s.push_str(rng.pick(TOKENS));
|
||||
}
|
||||
}
|
||||
if let Outcome::Panicked(p) = run(&s) {
|
||||
panics.push((s, p));
|
||||
}
|
||||
}
|
||||
assert!(panics.is_empty(), "RaTeX panicked: {panics:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutated_valid_input_never_panics() {
|
||||
let mut rng = Rng(0x2545_f491_4f6c_dd1d);
|
||||
let mut panics = Vec::new();
|
||||
for _ in 0..3000 {
|
||||
let mut v: Vec<char> = rng.pick(COVERAGE).chars().collect();
|
||||
for _ in 0..1 + rng.below(4) {
|
||||
if v.is_empty() {
|
||||
break;
|
||||
}
|
||||
let at = rng.below(v.len() + 1);
|
||||
match rng.below(4) {
|
||||
0 if at < v.len() => {
|
||||
v.remove(at);
|
||||
}
|
||||
1 => {
|
||||
for c in rng.pick(TOKENS).chars().rev() {
|
||||
v.insert(at.min(v.len()), c);
|
||||
}
|
||||
}
|
||||
2 if at < v.len() => v[at] = char::from_u32(rng.below(0x2200) as u32).unwrap_or('?'),
|
||||
_ => {
|
||||
let b = rng.below(v.len());
|
||||
let e = (b + rng.below(12)).min(v.len());
|
||||
let seg: Vec<char> = v[b..e].to_vec();
|
||||
for c in seg.into_iter().rev() {
|
||||
v.insert(at.min(v.len()), c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let s: String = v.into_iter().collect();
|
||||
if let Outcome::Panicked(p) = run(&s) {
|
||||
panics.push((s, p));
|
||||
}
|
||||
}
|
||||
assert!(panics.is_empty(), "RaTeX panicked: {panics:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nesting_is_refused_before_the_stack_runs_out() {
|
||||
for (open, close) in [("{", "}"), ("\\frac{", "}{1}"), ("\\sqrt{", "}"), ("\\left(", "\\right)"), ("x^{", "}")] {
|
||||
let at = |d: usize| format!("{}x{}", open.repeat(d), close.repeat(d));
|
||||
assert!(matches!(run(&at(32)), Outcome::Laid(_)), "{open}: 32 levels lay out");
|
||||
for d in [64, 2000] {
|
||||
assert_eq!(run(&at(d)), Outcome::Rejected("Recursion limit exceeded".into()), "{open}: {d} levels");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nesting_built_by_macros_is_refused_by_the_parser() {
|
||||
// `check_caps` in the wrapper counts the braces, `\left`s and `\begin`s of the source text,
|
||||
// so nesting that macro expansion builds reaches the parser; its own recursion guard refuses
|
||||
// it, well within the 1,000-expansion limit (64 levels take 128 expansions).
|
||||
let built = |d: usize| format!("\\def\\o{{\\left(}}\\def\\c{{\\right)}}{}x{}", "\\o ".repeat(d), "\\c".repeat(d));
|
||||
assert!(matches!(run(&built(32)), Outcome::Laid(_)), "32 macro-built levels lay out");
|
||||
assert_eq!(run(&built(64)), Outcome::Rejected("Recursion limit exceeded".into()), "64 macro-built levels");
|
||||
let frac = |d: usize| format!("\\def\\o#1{{\\frac{{#1}}{{1}}}}{}x{}", "\\o{".repeat(d), "}".repeat(d));
|
||||
assert!(matches!(run(&frac(32)), Outcome::Laid(_)), "32 macro-built fractions lay out");
|
||||
assert_eq!(run(&frac(64)), Outcome::Rejected("Recursion limit exceeded".into()), "64 macro-built fractions");
|
||||
// The wrapper's text count sees no nesting in the first source, so the parser's guard is the
|
||||
// one that holds there too.
|
||||
assert!(gridcraft_math::layout(&built(64)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macro_bombs_and_huge_dimensions_stop() {
|
||||
for bomb in [
|
||||
"\\def\\a{\\a\\a}\\a",
|
||||
"\\def\\a{xxxxxxxxxx}\\def\\b{\\a\\a\\a\\a\\a\\a\\a\\a\\a\\a}\\def\\c{\\b\\b\\b\\b\\b\\b\\b\\b\\b\\b}\\def\\d{\\c\\c\\c\\c\\c\\c\\c\\c\\c\\c}\\d",
|
||||
] {
|
||||
assert!(matches!(run(bomb), Outcome::Rejected(m) if m.contains("Too many expansions")));
|
||||
}
|
||||
// Absurd sizes lay out (the wrapper in this crate caps them); NaN is refused.
|
||||
for s in ["\\kern99999999em x", "\\rule{1e308em}{1e308em}", "\\hspace{-99999em}x"] {
|
||||
assert!(matches!(run(s), Outcome::Laid(_)), "{s}");
|
||||
}
|
||||
assert!(matches!(run("\\rule{nanem}{1em}"), Outcome::Rejected(_)));
|
||||
assert!(matches!(run(&"x+".repeat(2048)), Outcome::Laid(4096)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_syntax_is_covered() {
|
||||
let missing: Vec<(&str, Outcome)> = COVERAGE.iter().map(|s| (*s, run(s))).filter(|(_, o)| !matches!(o, Outcome::Laid(n) if *n > 0)).collect();
|
||||
assert!(missing.is_empty(), "unsupported: {missing:?}");
|
||||
}
|
||||
|
||||
/// Whether every number in `list` is finite and within the caps the wrapper promises.
|
||||
fn within_caps(list: &gridcraft_math::MathList) -> bool {
|
||||
use gridcraft_math::{Item, MAX_EM, MAX_ITEMS, PathCmd};
|
||||
let ok = |v: f32| v.is_finite() && v.abs() <= MAX_EM;
|
||||
let path_ok = |c: &PathCmd| match *c {
|
||||
PathCmd::MoveTo(a, b) | PathCmd::LineTo(a, b) => ok(a) && ok(b),
|
||||
PathCmd::QuadTo(a, b, c, d) => [a, b, c, d].into_iter().all(ok),
|
||||
PathCmd::CubicTo(a, b, c, d, e, f) => [a, b, c, d, e, f].into_iter().all(ok),
|
||||
PathCmd::Close => true,
|
||||
};
|
||||
list.items.len() <= MAX_ITEMS
|
||||
&& [list.width, list.height, list.depth].into_iter().all(ok)
|
||||
&& list.items.iter().all(|i| match i {
|
||||
Item::Glyph(g) => [g.x, g.y, g.size, g.width, g.height, g.depth].into_iter().all(ok),
|
||||
Item::Rule(r) => [r.x, r.y, r.width, r.height].into_iter().all(ok),
|
||||
Item::Path(p) => ok(p.x) && ok(p.y) && p.commands.iter().all(path_ok),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_guarded_entry_points_survive_hostile_input() {
|
||||
// What every caller (commands, the editor's preview, the xlsx writer) reaches: `layout`,
|
||||
// `check` and `to_omml`, with their caps. Random token soup, mutations of valid input, and
|
||||
// inputs at and past the caps; none may panic, a layout stays within its caps, and an error's
|
||||
// span stays on char boundaries inside the source.
|
||||
let mut rng = Rng(0x6a09_e667_f3bc_c909);
|
||||
let mut inputs: Vec<String> = Vec::new();
|
||||
for _ in 0..1500 {
|
||||
let mut s = String::new();
|
||||
for _ in 0..1 + rng.below(32) {
|
||||
match rng.below(12) {
|
||||
0 => s.push(char::from_u32(rng.below(0x11_0000) as u32).unwrap_or('\u{FFFD}')),
|
||||
1 => s.push_str(rng.pick(COVERAGE)),
|
||||
_ => s.push_str(rng.pick(TOKENS)),
|
||||
}
|
||||
}
|
||||
inputs.push(s);
|
||||
}
|
||||
for (open, close) in [("{", "}"), ("\\left(", "\\right)"), ("\\begin{matrix}", "\\end{matrix}"), ("\\sqrt{", "}")] {
|
||||
for d in [31, 32, 33, 500] {
|
||||
inputs.push(format!("{}x{}", open.repeat(d), close.repeat(d)));
|
||||
}
|
||||
}
|
||||
let cap = gridcraft_math::MAX_SOURCE_BYTES;
|
||||
inputs.push("x".repeat(cap));
|
||||
inputs.push("x".repeat(cap + 1));
|
||||
inputs.push(format!("{}\u{e9}", "x".repeat(cap - 1)));
|
||||
inputs.push("\\rule{1e308em}{1e308em}".into());
|
||||
inputs.push("\\def\\a{\\a\\a}\\a".into());
|
||||
let mut failures = Vec::new();
|
||||
for s in &inputs {
|
||||
let r = catch_unwind(AssertUnwindSafe(|| {
|
||||
let laid = gridcraft_math::layout(s);
|
||||
let checked = gridcraft_math::check(s);
|
||||
let omml = gridcraft_math::to_omml(s, 11.0, None);
|
||||
(laid, checked, omml)
|
||||
}));
|
||||
let Ok((laid, checked, omml)) = r else {
|
||||
failures.push(format!("panicked: {s:?}"));
|
||||
continue;
|
||||
};
|
||||
if let Ok(list) = &laid
|
||||
&& !within_caps(list)
|
||||
{
|
||||
failures.push(format!("outside the caps: {s:?}"));
|
||||
}
|
||||
for e in [laid.as_ref().err(), checked.as_ref().err(), omml.as_ref().err()].into_iter().flatten() {
|
||||
if let Some(span) = &e.span
|
||||
&& !(span.start <= span.end && span.end <= s.len() && s.is_char_boundary(span.start) && s.is_char_boundary(span.end))
|
||||
{
|
||||
failures.push(format!("span {span:?} off the source: {s:?}"));
|
||||
}
|
||||
}
|
||||
if laid.is_ok() && checked.is_err() {
|
||||
failures.push(format!("lays out but does not check: {s:?}"));
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{failures:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::disallowed_methods)] // tests run only off wasm, where the clock exists
|
||||
fn pathological_source_at_the_cap_finishes_in_bounded_time() {
|
||||
// Sources of exactly `MAX_SOURCE_BYTES` built to make a parser or a regular expression work
|
||||
// hardest: groups and delimiters nested to the depth cap, long runs of backslashes, braces and
|
||||
// scripts, a `(a+)+b` shape, wide and tall environments, and chemistry. `\ce` and `\pu` are
|
||||
// the one place RaTeX runs regular expressions over the source (its mhchem port uses
|
||||
// `fancy-regex`, which stops a match after a million backtracking steps); the rest of the
|
||||
// parser is hand written. Each source lays out, checks and converts, or is refused, well
|
||||
// inside the bound (the slowest, `\ce{+-+-...}`, takes 0.8 s in a debug build on an Apple M
|
||||
// series machine; catastrophic backtracking would take minutes), so a regression in RaTeX or
|
||||
// its dependencies shows up here without making a slow CI runner fail.
|
||||
use gridcraft_math::{MAX_DEPTH, MAX_SOURCE_BYTES, check, layout, to_omml};
|
||||
use std::time::{Duration, Instant};
|
||||
let fill = |head: &str, body: &str, tail: &str| {
|
||||
let room = MAX_SOURCE_BYTES.saturating_sub(head.len() + tail.len());
|
||||
let mut s = String::from(head);
|
||||
while s.len() + body.len() <= head.len() + room {
|
||||
s.push_str(body);
|
||||
}
|
||||
while s.len() < MAX_SOURCE_BYTES - tail.len() {
|
||||
s.push(' ');
|
||||
}
|
||||
s.push_str(tail);
|
||||
assert_eq!(s.len(), MAX_SOURCE_BYTES);
|
||||
s
|
||||
};
|
||||
let deep = |open: &str, close: &str| fill(&open.repeat(MAX_DEPTH), "x", &close.repeat(MAX_DEPTH));
|
||||
let sources = [
|
||||
deep("{", "}"),
|
||||
deep("\\left(", "\\right)"),
|
||||
deep("\\frac{", "}{1}"),
|
||||
deep("\\sqrt{", "}"),
|
||||
deep("x^{", "}"),
|
||||
deep("\\hat{", "}"),
|
||||
deep("\\text{", "}"),
|
||||
deep("\\begin{matrix}", "\\end{matrix}"),
|
||||
fill("", "\\\\", ""),
|
||||
fill("", "\\{", ""),
|
||||
fill("", "{}", ""),
|
||||
fill("", "(a+)+", "b"),
|
||||
fill("", "x^", "x"),
|
||||
fill("", "'", ""),
|
||||
fill("", "\\,", ""),
|
||||
fill("", "&", ""),
|
||||
fill("\\begin{matrix}", "&", "\\end{matrix}"),
|
||||
fill("\\begin{aligned}", "\\\\", "\\end{aligned}"),
|
||||
fill("\\begin{cases}", "x&\\\\", "\\end{cases}"),
|
||||
fill("", "\\def\\a{x}", ""),
|
||||
fill("", "\\color{red}", ""),
|
||||
fill("", "\u{0301}", ""),
|
||||
fill("", "\\mathbb{R}", ""),
|
||||
fill("", "\\operatorname{f}", ""),
|
||||
fill("\\ce{", "1", "}"),
|
||||
fill("\\ce{", "0.", "}"),
|
||||
fill("\\ce{", "(a", "}"),
|
||||
fill("\\ce{", "->", "}"),
|
||||
fill("\\ce{", "^_", "}"),
|
||||
fill("\\ce{", "$", "}"),
|
||||
fill("\\ce{", "H2O ", "}"),
|
||||
fill("\\ce{", "\\ce{", "}"),
|
||||
fill("\\ce{", "+-", "}"),
|
||||
fill("\\ce{", "1/2", "}"),
|
||||
fill("\\pu{", "+-", "}"),
|
||||
fill("\\pu{", "1e", "}"),
|
||||
fill("\\pu{", "10^", "}"),
|
||||
fill("\\pu{", "kJ//", "}"),
|
||||
];
|
||||
// A CI regression threshold with headroom for shared-runner variance, not a runtime deadline.
|
||||
let bound = Duration::from_secs(15);
|
||||
let mut slow = Vec::new();
|
||||
for s in &sources {
|
||||
let start = Instant::now();
|
||||
let r = catch_unwind(AssertUnwindSafe(|| (layout(s).is_ok(), check(s).is_ok(), to_omml(s, 11.0, None).is_ok())));
|
||||
let took = start.elapsed();
|
||||
let head: String = s.chars().take(24).collect();
|
||||
match r {
|
||||
Ok(_) if took <= bound => {}
|
||||
Ok(_) => slow.push(format!("{head:?}: {took:?}")),
|
||||
Err(_) => slow.push(format!("{head:?}: panicked")),
|
||||
}
|
||||
}
|
||||
assert!(slow.is_empty(), "over {bound:?}: {slow:#?}");
|
||||
}
|
||||
@@ -12,6 +12,7 @@ workspace = true
|
||||
gridcraft-core = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
gridcraft-formula = { workspace = true }
|
||||
gridcraft-math = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -6,6 +6,7 @@ use gridcraft_core::{RangeRef, Value};
|
||||
use gridcraft_formula::Expr;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::python::PyCode;
|
||||
use crate::style::StyleId;
|
||||
|
||||
/// A parsed formula. `text` is the canonical formula text without the leading `=`.
|
||||
@@ -60,25 +61,45 @@ pub struct CellPicture {
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Cell {
|
||||
/// Constant value, or the cached result of the formula.
|
||||
/// Constant value, or the cached result of the formula or Python code.
|
||||
pub value: Value,
|
||||
pub formula: Option<Arc<Formula>>,
|
||||
pub style: StyleId,
|
||||
/// Python code, for a Python cell (which has no formula).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub py: Option<Arc<PyCode>>,
|
||||
}
|
||||
|
||||
impl Cell {
|
||||
pub fn value(v: Value) -> Cell {
|
||||
Cell { value: v, formula: None, style: StyleId::DEFAULT }
|
||||
Cell { value: v, formula: None, style: StyleId::DEFAULT, py: None }
|
||||
}
|
||||
pub fn formula(f: Formula) -> Cell {
|
||||
Cell { value: Value::Empty, formula: Some(Arc::new(f)), style: StyleId::DEFAULT }
|
||||
Cell { value: Value::Empty, formula: Some(Arc::new(f)), style: StyleId::DEFAULT, py: None }
|
||||
}
|
||||
/// No value, no formula and the default style: storing it is pointless.
|
||||
/// A value cell with this style.
|
||||
pub fn styled(v: Value, style: StyleId) -> Cell {
|
||||
Cell { value: v, formula: None, style, py: None }
|
||||
}
|
||||
/// No value, no formula, no Python code and the default style: storing it is pointless.
|
||||
pub fn is_blank(&self) -> bool {
|
||||
self.value.is_empty() && self.formula.is_none() && self.style == StyleId::DEFAULT
|
||||
self.value.is_empty() && self.formula.is_none() && self.style == StyleId::DEFAULT && self.py.is_none()
|
||||
}
|
||||
/// What the formula bar shows.
|
||||
/// Whether the cell holds content (a value, a formula or Python code), formats aside.
|
||||
pub fn has_content(&self) -> bool {
|
||||
!self.value.is_empty() || self.formula.is_some() || self.py.is_some()
|
||||
}
|
||||
/// Removes the content (value, formula and Python code), keeping the style.
|
||||
pub fn clear_content(&mut self) {
|
||||
self.value = Value::Empty;
|
||||
self.formula = None;
|
||||
self.py = None;
|
||||
}
|
||||
/// What the formula bar shows: a formula, a Python cell's code, or the value.
|
||||
pub fn input_text(&self) -> String {
|
||||
if let Some(py) = &self.py {
|
||||
return py.code.clone();
|
||||
}
|
||||
if let Some(f) = &self.formula {
|
||||
return format!("={}", f.text);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ mod pivot;
|
||||
mod print;
|
||||
|
||||
pub use pivot::{PivotCalcField, PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotLayout, PivotShowAs, PivotSort, PivotTable, PivotValue};
|
||||
pub use print::{EditRange, HfPage, LineInfo, Orientation, PrintSettings, SheetProtection, password_hash};
|
||||
pub use print::{EditRange, HfPage, LineInfo, Orientation, PageOrder, PrintSettings, SheetProtection, password_hash};
|
||||
|
||||
// ---------------------------------------------------------------- tables
|
||||
|
||||
@@ -540,6 +540,22 @@ impl Trendline {
|
||||
}
|
||||
}
|
||||
|
||||
/// A chart's data table (`c:dTable`), independent of the chart legend.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ChartDataTable {
|
||||
pub show_keys: bool,
|
||||
pub horizontal_border: bool,
|
||||
pub vertical_border: bool,
|
||||
pub outline: bool,
|
||||
}
|
||||
|
||||
impl Default for ChartDataTable {
|
||||
fn default() -> Self {
|
||||
Self { show_keys: true, horizontal_border: true, vertical_border: true, outline: true }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Chart {
|
||||
pub id: u32,
|
||||
@@ -549,6 +565,8 @@ pub struct Chart {
|
||||
pub series: Vec<Series>,
|
||||
pub legend: LegendPos,
|
||||
pub data_labels: bool,
|
||||
#[serde(default)]
|
||||
pub data_table: Option<ChartDataTable>,
|
||||
pub gridlines: bool,
|
||||
pub style: u32,
|
||||
pub x_title: Option<String>,
|
||||
@@ -562,6 +580,30 @@ pub struct Chart {
|
||||
pub pivot: Option<String>,
|
||||
}
|
||||
|
||||
impl Chart {
|
||||
/// Data tables belong to category-axis column, bar, line and area charts.
|
||||
pub fn supports_data_table(&self) -> bool {
|
||||
let eligible = |kind| {
|
||||
matches!(
|
||||
kind,
|
||||
ChartKind::ColumnClustered
|
||||
| ChartKind::ColumnStacked
|
||||
| ChartKind::ColumnStacked100
|
||||
| ChartKind::BarClustered
|
||||
| ChartKind::BarStacked
|
||||
| ChartKind::BarStacked100
|
||||
| ChartKind::Line
|
||||
| ChartKind::LineMarkers
|
||||
| ChartKind::LineStacked
|
||||
| ChartKind::Area
|
||||
| ChartKind::AreaStacked
|
||||
| ChartKind::AreaStacked100
|
||||
)
|
||||
};
|
||||
eligible(self.kind) || (self.kind == ChartKind::Combo && self.series.iter().all(|s| eligible(s.kind.unwrap_or(ChartKind::Line))))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Image {
|
||||
pub id: u32,
|
||||
@@ -589,6 +631,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,
|
||||
/// 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,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -602,6 +647,144 @@ pub struct Shape {
|
||||
/// Outline visibility and text formatting (DrawingML `a:ln`, `a:bodyPr`, `a:pPr`, `a:rPr`).
|
||||
#[serde(default)]
|
||||
pub format: ShapeFormat,
|
||||
/// An equation's layout, rebuilt from `text` and never saved.
|
||||
#[serde(skip)]
|
||||
pub math: EquationCache,
|
||||
}
|
||||
|
||||
/// The laid-out form of an equation's LaTeX source. It is derived data: rebuilt by
|
||||
/// [`EquationCache::refresh`] whenever the source changes, never saved, and ignored when shapes
|
||||
/// are compared. Painters read it and never lay equations out themselves, so nothing in a paint
|
||||
/// path can reach the layout engine.
|
||||
///
|
||||
/// An entry answers only for the source it was built from, compared in full: a hash match alone
|
||||
/// is not enough, so two sources whose hashes collide never share a layout. A source over
|
||||
/// [`gridcraft_math::MAX_SOURCE_BYTES`] never lays out, so [`EquationCache::get`] answers for it
|
||||
/// by its length alone, with [`gridcraft_math::SOURCE_TOO_LONG`], and an entry built from one holds
|
||||
/// neither the source nor its hash: a painter asking every frame about a source of megabytes read
|
||||
/// from a file does no work on it.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct EquationCache {
|
||||
/// A hash of the source the entry was built from (a quick first check).
|
||||
key: u64,
|
||||
/// The source's length in bytes.
|
||||
len: usize,
|
||||
/// The source itself, when it is within the length cap.
|
||||
source: Option<std::sync::Arc<str>>,
|
||||
layout: Option<Result<std::sync::Arc<gridcraft_math::MathList>, gridcraft_math::MathError>>,
|
||||
}
|
||||
|
||||
impl PartialEq for EquationCache {
|
||||
fn eq(&self, _: &Self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn source_key(src: &str) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
#[cfg(test)]
|
||||
equation_cache_tests::HASHES.with(|h| h.set(h.get() + 1));
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
src.hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
impl EquationCache {
|
||||
/// Whether this entry was built from `src`. Over the cap the length is the match (an entry for
|
||||
/// such a source is always the same error); within it, the hash and then the source itself.
|
||||
fn holds(&self, src: &str) -> bool {
|
||||
if self.layout.is_none() || self.len != src.len() {
|
||||
return false;
|
||||
}
|
||||
if src.len() > gridcraft_math::MAX_SOURCE_BYTES {
|
||||
return self.source.is_none();
|
||||
}
|
||||
self.key == source_key(src) && self.source.as_deref() == Some(src)
|
||||
}
|
||||
|
||||
/// Makes this the entry for `src`, with `layout`. A source over the cap is neither copied nor
|
||||
/// hashed.
|
||||
fn store(&mut self, src: &str, layout: Result<std::sync::Arc<gridcraft_math::MathList>, gridcraft_math::MathError>) {
|
||||
let within = src.len() <= gridcraft_math::MAX_SOURCE_BYTES;
|
||||
self.key = if within { source_key(src) } else { 0 };
|
||||
self.len = src.len();
|
||||
self.source = within.then(|| std::sync::Arc::from(src));
|
||||
self.layout = Some(layout);
|
||||
}
|
||||
|
||||
/// The layout of `src`, or why it has none; `None` until [`EquationCache::refresh`] has run
|
||||
/// for this source. A source over [`gridcraft_math::MAX_SOURCE_BYTES`] is answered at once,
|
||||
/// whatever the entry holds, by its length alone.
|
||||
pub fn get(&self, src: &str) -> Option<Result<&gridcraft_math::MathList, &gridcraft_math::MathError>> {
|
||||
if src.len() > gridcraft_math::MAX_SOURCE_BYTES {
|
||||
return Some(Err(&*gridcraft_math::SOURCE_TOO_LONG));
|
||||
}
|
||||
if !self.holds(src) {
|
||||
return None;
|
||||
}
|
||||
self.layout.as_ref().map(|r| r.as_ref().map(|l| l.as_ref()))
|
||||
}
|
||||
|
||||
/// Lays `src` out again unless the cache already holds its layout. True when it did.
|
||||
pub fn refresh(&mut self, src: &str) -> bool {
|
||||
if self.holds(src) {
|
||||
return false;
|
||||
}
|
||||
self.store(src, gridcraft_math::layout(src).map(std::sync::Arc::new));
|
||||
true
|
||||
}
|
||||
|
||||
/// Records that `src` was not laid out, and why, without running the layout engine.
|
||||
pub fn refuse(&mut self, src: &str, message: &str) {
|
||||
self.store(src, Err(gridcraft_math::MathError { span: None, message: message.to_string() }));
|
||||
}
|
||||
}
|
||||
|
||||
/// The kinds of floating object a sheet holds. Commands name an object by kind and
|
||||
/// workbook-unique id (`{kind: "chart", id}`); [`ObjKind::name`] is that parameter's spelling.
|
||||
/// Equations are stored as shapes ([`ShapeKind::Equation`]) but select, open and delete as their
|
||||
/// own kind.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ObjKind {
|
||||
Chart,
|
||||
Image,
|
||||
Shape,
|
||||
Slicer,
|
||||
Equation,
|
||||
}
|
||||
|
||||
impl ObjKind {
|
||||
pub const ALL: [ObjKind; 5] = [ObjKind::Chart, ObjKind::Image, ObjKind::Shape, ObjKind::Slicer, ObjKind::Equation];
|
||||
|
||||
/// The name used in command parameters.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
ObjKind::Chart => "chart",
|
||||
ObjKind::Image => "image",
|
||||
ObjKind::Shape => "shape",
|
||||
ObjKind::Slicer => "slicer",
|
||||
ObjKind::Equation => "equation",
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind named `s` (any letter case).
|
||||
pub fn parse(s: &str) -> Option<ObjKind> {
|
||||
ObjKind::ALL.into_iter().find(|k| k.name().eq_ignore_ascii_case(s))
|
||||
}
|
||||
}
|
||||
|
||||
/// One floating object on a sheet: its kind and its workbook-unique id.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct ObjRef {
|
||||
pub kind: ObjKind,
|
||||
pub id: u32,
|
||||
}
|
||||
|
||||
impl ObjRef {
|
||||
pub fn new(kind: ObjKind, id: u32) -> ObjRef {
|
||||
ObjRef { kind, id }
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizontal alignment of a shape's text (`a:pPr algn`).
|
||||
@@ -737,3 +920,75 @@ mod trendline_forecast_tests {
|
||||
assert_eq!(Trendline::clamp_forecast(1e300), Trendline::MAX_FORECAST);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod equation_cache_tests {
|
||||
use super::{EquationCache, source_key};
|
||||
|
||||
thread_local! {
|
||||
/// How many sources `source_key` has hashed on this thread.
|
||||
pub(super) static HASHES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
fn glyphs(c: &EquationCache, src: &str) -> Option<String> {
|
||||
let list = c.get(src)?.ok()?;
|
||||
Some(list.items.iter().filter_map(|i| if let gridcraft_math::Item::Glyph(g) = i { Some(g.ch) } else { None }).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hash_collision_never_returns_another_sources_layout() {
|
||||
let (a, b) = ("x^2", "y^3");
|
||||
let mut c = EquationCache::default();
|
||||
assert!(c.refresh(a));
|
||||
assert_eq!(glyphs(&c, a).as_deref(), Some("x2"));
|
||||
// Forge a collision: the entry claims `b`'s hash; `b` has `a`'s length, so only the
|
||||
// full comparison tells them apart.
|
||||
c.key = source_key(b);
|
||||
assert!(c.get(b).is_none(), "`b` is a miss, not `a`'s layout");
|
||||
assert!(c.refresh(b), "`b` is laid out afresh");
|
||||
assert_eq!(glyphs(&c, b).as_deref(), Some("y3"));
|
||||
assert!(c.get(a).is_none(), "the entry is `b`'s now");
|
||||
// A refusal does not answer for a colliding source either.
|
||||
c.refuse(a, "not laid out");
|
||||
c.key = source_key(b);
|
||||
assert!(c.get(b).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sources_over_the_cap_are_not_copied_hashed_or_laid_out() {
|
||||
let long = "x".repeat(gridcraft_math::MAX_SOURCE_BYTES + 1);
|
||||
let mut c = EquationCache::default();
|
||||
HASHES.with(|h| h.set(0));
|
||||
assert!(matches!(c.get(&long), Some(Err(e)) if e.message.contains("4096")), "answered before any refresh");
|
||||
assert!(c.refresh(&long));
|
||||
assert!(c.source.is_none(), "an over-cap source is not kept");
|
||||
assert_eq!(c.key, 0, "nor hashed");
|
||||
assert!(matches!(c.get(&long), Some(Err(_))));
|
||||
assert!(!c.refresh(&long), "still cached");
|
||||
let other = "y".repeat(gridcraft_math::MAX_SOURCE_BYTES + 1);
|
||||
assert!(matches!(c.get(&other), Some(Err(_))), "another over-cap source gets the same answer");
|
||||
assert_eq!(HASHES.with(|h| h.get()), 0, "nothing over the cap is hashed");
|
||||
// Within the cap the lookup is unchanged: a hash, then the source compared in full.
|
||||
assert!(c.refresh("x"));
|
||||
assert!(c.get("x").is_some_and(|r| r.is_ok()));
|
||||
assert!(HASHES.with(|h| h.get()) >= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::disallowed_methods)] // tests run only off wasm, where the clock exists
|
||||
fn a_source_of_a_megabyte_is_looked_up_without_being_read() {
|
||||
// The sheet painter asks the cache every frame and the PDF export every draw; a source
|
||||
// read from a file can be megabytes, and the lookup must not scan it each time.
|
||||
let big = "l".repeat(1 << 20);
|
||||
let mut c = EquationCache::default();
|
||||
c.refuse(&big, "budget");
|
||||
HASHES.with(|h| h.set(0));
|
||||
let start = std::time::Instant::now();
|
||||
for _ in 0..1000 {
|
||||
assert!(matches!(c.get(&big), Some(Err(_))));
|
||||
}
|
||||
let took = start.elapsed();
|
||||
assert_eq!(HASHES.with(|h| h.get()), 0, "a thousand lookups read nothing");
|
||||
assert!(took < std::time::Duration::from_millis(500), "{took:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,12 +49,52 @@ pub struct PrintSettings {
|
||||
pub hf_scale_with_doc: bool,
|
||||
#[serde(default = "yes")]
|
||||
pub hf_align_with_margins: bool,
|
||||
/// Page Setup › Sheet › Page order: the sequence pages are numbered and printed in when the
|
||||
/// sheet is more than one page wide and tall.
|
||||
#[serde(default)]
|
||||
pub page_order: PageOrder,
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// The sequence of a sheet's pages (XLSX `pageSetup pageOrder`, ECMA-376 `ST_PageOrder`).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PageOrder {
|
||||
/// Down the first column of pages, then down the next one (Excel's default).
|
||||
#[default]
|
||||
DownThenOver,
|
||||
/// Across the first row of pages, then across the next one.
|
||||
OverThenDown,
|
||||
}
|
||||
|
||||
impl PageOrder {
|
||||
/// The id `pageLayout.pageSetup` takes and XLSX writes.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
PageOrder::DownThenOver => "downThenOver",
|
||||
PageOrder::OverThenDown => "overThenDown",
|
||||
}
|
||||
}
|
||||
pub fn parse(id: &str) -> Option<PageOrder> {
|
||||
match id {
|
||||
"downThenOver" => Some(PageOrder::DownThenOver),
|
||||
"overThenDown" => Some(PageOrder::OverThenDown),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// The 0-based place in the printed sequence of the page in page row `row` and page column
|
||||
/// `col`, on a sheet `rows` pages tall and `cols` pages wide.
|
||||
pub fn index(self, row: usize, col: usize, rows: usize, cols: usize) -> usize {
|
||||
match self {
|
||||
PageOrder::DownThenOver => col.saturating_mul(rows).saturating_add(row),
|
||||
PageOrder::OverThenDown => row.saturating_mul(cols).saturating_add(col),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Which header/footer pair a printed page uses.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum HfPage {
|
||||
@@ -146,6 +186,7 @@ impl Default for PrintSettings {
|
||||
even_footer: String::new(),
|
||||
hf_scale_with_doc: true,
|
||||
hf_align_with_margins: true,
|
||||
page_order: PageOrder::DownThenOver,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,3 +256,29 @@ pub struct LineInfo {
|
||||
#[serde(default)]
|
||||
pub custom: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn page_order_numbers_pages_and_old_settings_load_down_then_over() {
|
||||
// Two pages tall, three wide: (row, col) → place in the printed sequence.
|
||||
let place = |order: PageOrder| [(0, 0), (1, 0), (0, 1), (1, 1), (0, 2), (1, 2)].map(|(r, c)| order.index(r, c, 2, 3));
|
||||
assert_eq!(place(PageOrder::DownThenOver), [0, 1, 2, 3, 4, 5]);
|
||||
assert_eq!(place(PageOrder::OverThenDown), [0, 3, 1, 4, 2, 5]);
|
||||
assert_eq!(PageOrder::OverThenDown.index(usize::MAX, usize::MAX, usize::MAX, usize::MAX), usize::MAX);
|
||||
for order in [PageOrder::DownThenOver, PageOrder::OverThenDown] {
|
||||
assert_eq!(PageOrder::parse(order.id()), Some(order));
|
||||
}
|
||||
assert_eq!(PageOrder::parse("OverThenDown"), None);
|
||||
// Saved under the same id the command takes; settings saved before the field existed load
|
||||
// with Excel's default.
|
||||
let mut p = PrintSettings { page_order: PageOrder::OverThenDown, ..Default::default() };
|
||||
let mut saved = serde_json::to_value(&p).unwrap();
|
||||
assert_eq!(saved["page_order"], "overThenDown");
|
||||
assert!(saved.as_object_mut().unwrap().remove("page_order").is_some());
|
||||
p = serde_json::from_value(saved).unwrap();
|
||||
assert_eq!(p, PrintSettings::default());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod features;
|
||||
pub mod macros;
|
||||
pub mod password;
|
||||
pub mod preserved;
|
||||
pub mod python;
|
||||
pub mod sheet;
|
||||
pub mod store;
|
||||
pub mod style;
|
||||
@@ -21,13 +22,17 @@ pub use features::*;
|
||||
pub use macros::*;
|
||||
pub use password::PasswordHash;
|
||||
pub use preserved::{Preserved, PreservedPart, PreservedRel, PreservedSheet, PreservedXml};
|
||||
pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, SheetViewMode, Visibility, points_to_chars};
|
||||
pub use python::{PyCode, PyOutput};
|
||||
pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, SheetUid, SheetViewMode, Visibility, points_to_chars};
|
||||
pub use store::CellStore;
|
||||
pub use style::{
|
||||
Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, StyleId, StyleTable, Theme, Underline,
|
||||
VAlign, VertAlign,
|
||||
};
|
||||
pub use workbook::{CalcMode, CalcSettings, DefinedName, DocProps, PhoneticProps, PhoneticRun, PhoneticString, ShowObjects, Workbook, WorkbookView};
|
||||
pub use workbook::{
|
||||
CalcMode, CalcSettings, DefinedName, DocProps, EQUATION_BUDGET_MESSAGE, MAX_EQUATION_LAYOUT_BYTES, PhoneticProps, PhoneticRun, PhoneticString,
|
||||
ShowObjects, Workbook, WorkbookView,
|
||||
};
|
||||
|
||||
/// Font used for new workbooks. Only a name: renderers substitute an available open font
|
||||
/// (e.g. the metric-compatible Carlito) when it isn't installed.
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
//! Python cells: the code a cell holds, its last result, and the text forms around it (the
|
||||
//! `PY("…",0|1)` formula of the function's syntax, and the `xl("…")` references inside it). An
|
||||
//! xlsx file keeps the code in its Python part instead (see the xlsx crate).
|
||||
|
||||
use gridcraft_core::{CellError, Value};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Excel's limit on the characters of one formula. A Python cell's code must fit in it once it is
|
||||
/// written as the PY formula of the function's syntax ([`PyCode::to_file_formula`]): quotes are
|
||||
/// doubled and every 255 characters add `"&"`, so the limit on the code itself depends on what the
|
||||
/// code holds.
|
||||
pub const MAX_FORMULA_CHARS: usize = 8_192;
|
||||
|
||||
/// The most cells a Python result may have (a crafted file can claim any size).
|
||||
pub const MAX_RESULT_CELLS: usize = 1_000_000;
|
||||
|
||||
/// The prefix files put before `PY(`, as Excel writes it ([MS-XLSX]: `_xlfn._xlws.PY`).
|
||||
pub const FILE_PREFIX: &str = "_xlfn._xlws.";
|
||||
|
||||
/// Characters in one string literal of a `PY(…)` formula (Excel's limit for a literal).
|
||||
const CHUNK_CHARS: usize = 255;
|
||||
|
||||
/// A Python cell.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PyCode {
|
||||
pub code: String,
|
||||
/// Show the result as a Python object (true) or convert it to cell values (false).
|
||||
pub object: bool,
|
||||
/// The last result, when the code ran without an error.
|
||||
pub output: Option<PyOutput>,
|
||||
/// The cell's inputs changed while calculation was manual: its value is out of date until
|
||||
/// the workbook is calculated.
|
||||
pub stale: bool,
|
||||
}
|
||||
|
||||
/// A Python result kept on the cell.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PyOutput {
|
||||
/// The result as cells (an array spills), or its preview as text when it has no cell form.
|
||||
pub value: Value,
|
||||
/// Python's name for the result's type.
|
||||
pub type_name: String,
|
||||
/// The result's `repr`, shortened.
|
||||
pub preview: String,
|
||||
/// Whether `value` is the result converted to cells.
|
||||
pub convertible: bool,
|
||||
}
|
||||
|
||||
impl PyCode {
|
||||
pub fn new(code: &str) -> PyCode {
|
||||
PyCode { code: normalize_code(code), ..PyCode::default() }
|
||||
}
|
||||
|
||||
/// What the cell shows for its result: cell values (an array spills from the cell), or, for a
|
||||
/// Python object, its type name. `None` before the code has run.
|
||||
pub fn result_value(&self) -> Option<Value> {
|
||||
let out = self.output.as_ref()?;
|
||||
let scalar = !matches!(out.value, Value::Array(_)) && out.convertible;
|
||||
Some(if self.object && !scalar { Value::text(out.type_name.as_str()) } else { out.value.clone() })
|
||||
}
|
||||
|
||||
/// The cell as a formula (without `=`): `PY("…"&"…",0)`, with the code in string literals of
|
||||
/// at most 255 characters and the return type Excel documents: 0 for an Excel value, 1 for a
|
||||
/// Python object.
|
||||
pub fn to_formula(&self) -> String {
|
||||
let chunks = chunk(&self.code, CHUNK_CHARS);
|
||||
let lits: Vec<String> = chunks.iter().map(|c| format!("\"{}\"", c.replace('"', "\"\""))).collect();
|
||||
let lits = if lits.is_empty() { "\"\"".to_string() } else { lits.join("&") };
|
||||
format!("PY({lits},{})", u8::from(self.object))
|
||||
}
|
||||
|
||||
/// [`PyCode::to_formula`] with Excel's `_xlfn._xlws.` prefix: the string-literal form
|
||||
/// [`PyCode::from_formula`] reads from files that hold it, and what the size limit measures.
|
||||
/// An xlsx file names the code by index instead ([MS-XLSX] 2.3.10.2).
|
||||
pub fn to_file_formula(&self) -> String {
|
||||
format!("{FILE_PREFIX}{}", self.to_formula())
|
||||
}
|
||||
|
||||
/// Whether the code fits in a PY formula of at most [`MAX_FORMULA_CHARS`] characters.
|
||||
pub fn fits(&self) -> bool {
|
||||
// Code longer than the limit can't fit; this also bounds the work for hostile input.
|
||||
self.code.chars().nth(MAX_FORMULA_CHARS).is_none() && self.to_file_formula().chars().nth(MAX_FORMULA_CHARS).is_none()
|
||||
}
|
||||
|
||||
/// Reads a `PY(…)` formula (with or without `=`, and with or without the `_xlfn.` and
|
||||
/// `_xlws.` prefixes): string literals joined by `&`, optionally followed by `,0` or `,1`.
|
||||
/// `None` for anything else.
|
||||
pub fn from_formula(text: &str) -> Option<PyCode> {
|
||||
let body = py_body(text);
|
||||
let rest = body.get(..3).filter(|h| h.eq_ignore_ascii_case("PY(")).and_then(|_| body.get(3..))?;
|
||||
let mut chars = rest.chars().peekable();
|
||||
let mut code = String::new();
|
||||
loop {
|
||||
skip_space(&mut chars);
|
||||
if chars.next()? != '"' {
|
||||
return None;
|
||||
}
|
||||
loop {
|
||||
match chars.next()? {
|
||||
'"' if chars.peek() == Some(&'"') => {
|
||||
chars.next();
|
||||
code.push('"');
|
||||
}
|
||||
'"' => break,
|
||||
c => code.push(c),
|
||||
}
|
||||
}
|
||||
skip_space(&mut chars);
|
||||
match chars.next()? {
|
||||
'&' => continue,
|
||||
',' => {
|
||||
skip_space(&mut chars);
|
||||
let object = match chars.next()? {
|
||||
'0' => false,
|
||||
'1' => true,
|
||||
_ => return None,
|
||||
};
|
||||
skip_space(&mut chars);
|
||||
return (chars.next()? == ')' && chars.all(char::is_whitespace)).then(|| PyCode { object, ..PyCode::new(&code) });
|
||||
}
|
||||
')' => return chars.all(char::is_whitespace).then(|| PyCode::new(&code)),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `text` without a leading `=`, spaces and the `_xlfn.` and `_xlws.` prefixes files put before
|
||||
/// newer functions (in any letter case).
|
||||
fn py_body(text: &str) -> &str {
|
||||
let mut body = text.trim().trim_start_matches('=').trim_start();
|
||||
for prefix in ["_xlfn.", "_xlws."] {
|
||||
if body.get(..prefix.len()).is_some_and(|h| h.eq_ignore_ascii_case(prefix)) {
|
||||
body = body.get(prefix.len()..).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
fn skip_space(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) {
|
||||
while chars.peek().is_some_and(|c| c.is_whitespace()) {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
|
||||
/// Line breaks as `\n`.
|
||||
pub fn normalize_code(code: &str) -> String {
|
||||
code.replace("\r\n", "\n").replace('\r', "\n")
|
||||
}
|
||||
|
||||
/// `s` in pieces of at most `n` characters.
|
||||
fn chunk(s: &str, n: usize) -> Vec<String> {
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
chars.chunks(n.max(1)).map(|c| c.iter().collect()).collect()
|
||||
}
|
||||
|
||||
/// Whether `text` (a formula, with or without `=`) is a `PY(` call: such formulas are Python cells,
|
||||
/// set with `python.set`.
|
||||
pub fn is_py_formula(text: &str) -> bool {
|
||||
let body = py_body(text);
|
||||
body.get(..3).is_some_and(|h| h.eq_ignore_ascii_case("PY(")) || body.eq_ignore_ascii_case("PY")
|
||||
}
|
||||
|
||||
/// An `xl()` call in Python code.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct XlCall {
|
||||
/// The reference text, when the first argument is a plain string literal.
|
||||
pub reference: Option<String>,
|
||||
/// Byte range of the literal's contents (between the quotes) in the code.
|
||||
pub span: Option<std::ops::Range<usize>>,
|
||||
}
|
||||
|
||||
/// The `xl(…)` calls in `code`, skipping comments and other string literals. A call whose first
|
||||
/// argument isn't a plain string literal has no reference: the cells it reads are only known when
|
||||
/// it runs.
|
||||
pub fn xl_calls(code: &str) -> Vec<XlCall> {
|
||||
let bytes = code.as_bytes();
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0usize;
|
||||
while let Some(&b) = bytes.get(i) {
|
||||
match b {
|
||||
b'#' => {
|
||||
// A comment runs to the end of the line.
|
||||
while bytes.get(i).is_some_and(|c| *c != b'\n') {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
b'"' | b'\'' => i = skip_string(bytes, i),
|
||||
b'x' if bytes.get(i + 1) == Some(&b'l') && (i == 0 || bytes.get(i - 1).is_some_and(|p| !is_ident(*p) && *p != b'.')) => {
|
||||
let mut j = i + 2;
|
||||
if bytes.get(j).is_some_and(|c| is_ident(*c)) {
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
while bytes.get(j).is_some_and(|c| *c == b' ' || *c == b'\t') {
|
||||
j += 1;
|
||||
}
|
||||
if bytes.get(j) != Some(&b'(') {
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
j += 1;
|
||||
while bytes.get(j).is_some_and(|c| c.is_ascii_whitespace()) {
|
||||
j += 1;
|
||||
}
|
||||
let call = match bytes.get(j) {
|
||||
Some(&q @ (b'"' | b'\'')) => {
|
||||
let start = j + 1;
|
||||
let end = skip_string(bytes, j);
|
||||
// A plain literal: closed on its own line, not part of an expression
|
||||
// (`xl("A" + n)`), not a triple-quoted string, and escaping nothing but a
|
||||
// quote or a backslash (`\'`, `\"`, `\\`), which a sheet name may need.
|
||||
let close = end.saturating_sub(1);
|
||||
let mut k = end;
|
||||
while bytes.get(k).is_some_and(|c| *c == b' ' || *c == b'\t') {
|
||||
k += 1;
|
||||
}
|
||||
let plain = close > start
|
||||
&& bytes.get(close) == Some(&q)
|
||||
&& bytes.get(start) != Some(&q)
|
||||
&& matches!(bytes.get(k), Some(b')' | b','));
|
||||
i = end;
|
||||
match code.get(start..close).filter(|_| plain).and_then(unescape) {
|
||||
Some(t) => XlCall { reference: Some(t), span: Some(start..close) },
|
||||
None => XlCall { reference: None, span: None },
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
i = j;
|
||||
XlCall { reference: None, span: None }
|
||||
}
|
||||
};
|
||||
out.push(call);
|
||||
}
|
||||
_ => i += 1,
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn is_ident(b: u8) -> bool {
|
||||
b.is_ascii_alphanumeric() || b == b'_' || b >= 0x80
|
||||
}
|
||||
|
||||
/// The index just past the string literal starting at `at` (single, double or triple quoted).
|
||||
fn skip_string(bytes: &[u8], at: usize) -> usize {
|
||||
let Some(&q) = bytes.get(at) else { return at.saturating_add(1) };
|
||||
let triple = bytes.get(at + 1) == Some(&q) && bytes.get(at + 2) == Some(&q);
|
||||
let mut i = if triple { at + 3 } else { at + 1 };
|
||||
while let Some(&c) = bytes.get(i) {
|
||||
if c == b'\\' {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if c == q {
|
||||
if !triple {
|
||||
return i + 1;
|
||||
}
|
||||
if bytes.get(i + 1) == Some(&q) && bytes.get(i + 2) == Some(&q) {
|
||||
return i + 3;
|
||||
}
|
||||
} else if c == b'\n' && !triple {
|
||||
return i;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// The text of a plain literal's contents: `\'`, `\"` and `\\` stand for the character after the
|
||||
/// backslash. `None` for any other escape or a line break, which make the literal not plain.
|
||||
fn unescape(raw: &str) -> Option<String> {
|
||||
let mut out = String::with_capacity(raw.len());
|
||||
let mut chars = raw.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'\n' => return None,
|
||||
'\\' => match chars.next()? {
|
||||
e @ ('\\' | '\'' | '"') => out.push(e),
|
||||
_ => return None,
|
||||
},
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `text` as a plain literal that was delimited by `quote`: its delimiter (`quote`, or the other
|
||||
/// quote when `text` holds `quote` but not the other one, so `xl('Sheet2!B2')` becomes
|
||||
/// `xl("'Q4 2026'!B2")` on a rename) and its contents, with backslashes escaped, and the
|
||||
/// delimiter too when `text` holds both quotes. `None` for a line break, which no literal on
|
||||
/// one line holds.
|
||||
fn literal(text: &str, quote: char) -> Option<(char, String)> {
|
||||
if text.contains('\n') {
|
||||
return None;
|
||||
}
|
||||
let other = if quote == '\'' { '"' } else { '\'' };
|
||||
let delim = if text.contains(quote) && !text.contains(other) { other } else { quote };
|
||||
let mut body = String::with_capacity(text.len());
|
||||
for c in text.chars() {
|
||||
if c == '\\' || c == delim {
|
||||
body.push('\\');
|
||||
}
|
||||
body.push(c);
|
||||
}
|
||||
Some((delim, body))
|
||||
}
|
||||
|
||||
/// Rewrites the reference of every `xl("…")` literal in `code` with `f`; `None` from `f` leaves
|
||||
/// it. Returns the new code when anything changed. A new reference holding the literal's quote
|
||||
/// (a sheet name that needs quotes, `'Q4 2026'!B2`) gets the other quote as its delimiter, or an
|
||||
/// escaped quote when it holds both, so it never ends the string early.
|
||||
pub fn rewrite_xl(code: &str, mut f: impl FnMut(&str) -> Option<String>) -> Option<String> {
|
||||
let mut out = String::with_capacity(code.len());
|
||||
let mut last = 0usize;
|
||||
let mut changed = false;
|
||||
for call in xl_calls(code) {
|
||||
let (Some(reference), Some(span)) = (call.reference, call.span) else { continue };
|
||||
let Some(new) = f(&reference).filter(|n| *n != reference) else { continue };
|
||||
let open = span.start.saturating_sub(1);
|
||||
let Some(quote) = code.get(open..span.start).and_then(|q| q.chars().next()) else { continue };
|
||||
let Some((delim, body)) = literal(&new, quote) else { continue };
|
||||
// The literal with its quotes: the delimiter may change.
|
||||
out.push_str(code.get(last..open).unwrap_or_default());
|
||||
out.push(delim);
|
||||
out.push_str(&body);
|
||||
out.push(delim);
|
||||
last = span.end.saturating_add(1);
|
||||
changed = true;
|
||||
}
|
||||
if !changed {
|
||||
return None;
|
||||
}
|
||||
out.push_str(code.get(last..).unwrap_or_default());
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The value a Python cell shows when it can't run: `#BLOCKED!`.
|
||||
pub const BLOCKED: Value = Value::Error(CellError::Blocked);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn formula_round_trip_with_chunks_and_quotes() {
|
||||
let code = format!("s = \"quoted\"\n{}\nlen(s)", "x".repeat(600));
|
||||
let py = PyCode { object: true, ..PyCode::new(&code) };
|
||||
let f = py.to_formula();
|
||||
assert!(f.starts_with("PY(\"s = \"\"quoted\"\"\n"));
|
||||
assert!(f.ends_with(",1)"));
|
||||
assert_eq!(f.matches("\"&\"").count(), 2, "{f}");
|
||||
let back = PyCode::from_formula(&format!("={f}")).unwrap();
|
||||
assert_eq!(back.code, code);
|
||||
assert!(back.object);
|
||||
assert_eq!(PyCode::from_formula("_xlfn.PY(\"1+1\")").unwrap().code, "1+1");
|
||||
// Excel writes `_xlfn._xlws.PY`; the reader takes it in any letter case.
|
||||
assert_eq!(PyCode::from_formula("=_xlfn._xlws.PY(\"1+1\",1)").unwrap().code, "1+1");
|
||||
assert_eq!(PyCode::from_formula("_XLFN._XLWS.py(\"2\",0)").unwrap().code, "2");
|
||||
assert_eq!(PyCode::from_formula("_xlws.PY(\"3\",0)").unwrap().code, "3");
|
||||
assert!(is_py_formula("=_xlfn._xlws.PY(\"1\",0)"));
|
||||
assert_eq!(py.to_file_formula(), format!("_xlfn._xlws.{f}"));
|
||||
assert_eq!(PyCode::from_formula(&py.to_file_formula()).unwrap(), PyCode::from_formula(&f).unwrap());
|
||||
assert_eq!(PyCode::from_formula(" py( \"a\" & \"b\" , 0 ) ").unwrap().code, "ab");
|
||||
assert_eq!(PyCode::new("a\r\nb").code, "a\nb");
|
||||
for bad in ["PY(A1)", "PY(\"a\",2)", "PY(\"a\"", "PY(\"a\")+1", "SUM(1)", "PY(\"a\"&B1)", "PY(", ""] {
|
||||
assert!(PyCode::from_formula(bad).is_none(), "{bad}");
|
||||
}
|
||||
assert!(is_py_formula("=PY(\"1\")") && is_py_formula("=py") && !is_py_formula("=PYTHON(1)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_size_limit_counts_the_encoded_formula() {
|
||||
// Plain code: the limit is the formula's, so it is a little under 8,192 characters of code.
|
||||
// `_xlfn._xlws.PY(` + 31 literals of 255 + 30 `&` + `&"…"` + `,0)`: 8,079 characters of
|
||||
// code give a formula of exactly 8,192.
|
||||
let at = PyCode::new(&"y".repeat(8_079));
|
||||
assert_eq!(at.to_file_formula().chars().count(), MAX_FORMULA_CHARS);
|
||||
assert!(at.fits());
|
||||
assert!(!PyCode::new(&"y".repeat(8_080)).fits());
|
||||
assert_eq!(PyCode::from_formula(&at.to_file_formula()).unwrap().code, at.code);
|
||||
// Quotes are doubled in the file, so 7,000 characters with 1,200 of them don't fit.
|
||||
let quoted = format!("{}{}", "\"".repeat(1_200), "y".repeat(5_800));
|
||||
assert!(PyCode::new("ed).to_file_formula().chars().count() > MAX_FORMULA_CHARS);
|
||||
assert!(!PyCode::new("ed).fits());
|
||||
// Hostile sizes are refused without encoding them.
|
||||
assert!(!PyCode::new(&"y".repeat(4_000_000)).fits());
|
||||
assert!(PyCode::new("").fits());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_values() {
|
||||
let arr = gridcraft_core::Array::new(2, 1, vec![1.0.into(), 2.0.into()]).map(Value::from).unwrap();
|
||||
let out = PyOutput { value: arr.clone(), type_name: "list".into(), preview: "[1, 2]".into(), convertible: true };
|
||||
let mut py = PyCode { output: Some(out), ..PyCode::new("[1, 2]") };
|
||||
assert_eq!(py.result_value(), Some(arr));
|
||||
py.object = true;
|
||||
assert_eq!(py.result_value(), Some(Value::text("list")));
|
||||
py.output = Some(PyOutput { value: Value::Number(3.0), type_name: "int".into(), preview: "3".into(), convertible: true });
|
||||
assert_eq!(py.result_value(), Some(Value::Number(3.0)), "scalars show as values in object mode too");
|
||||
py.output = None;
|
||||
assert_eq!(py.result_value(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_xl_literals() {
|
||||
let code = "a = xl(\"A1:B2\", headers=True)\n# xl('C1') in a comment\ns = \"xl('D1')\"\nb = xl( 'Sheet2!E5' )\nc = xl(name)\nd = df.xl('F1')\nxlx('G1')\ne = xl(\"A\" + \"1\")";
|
||||
let calls = xl_calls(code);
|
||||
let refs: Vec<Option<&str>> = calls.iter().map(|c| c.reference.as_deref()).collect();
|
||||
assert_eq!(refs, vec![Some("A1:B2"), Some("Sheet2!E5"), None, None]);
|
||||
let new = rewrite_xl(code, |r| (r == "A1:B2").then(|| "A2:B3".to_string())).unwrap();
|
||||
assert!(new.starts_with("a = xl(\"A2:B3\", headers=True)"));
|
||||
assert_eq!(new.len(), code.len());
|
||||
assert_eq!(rewrite_xl(code, |_| None), None);
|
||||
// A replacement can't break out of its string: a quote in it changes the delimiter, or is
|
||||
// escaped when it holds both quotes, and the result reads back as the same reference.
|
||||
assert_eq!(rewrite_xl("xl(\"A1\")", |_| Some("'Q'!A1".into())), Some("xl(\"'Q'!A1\")".into()));
|
||||
assert_eq!(rewrite_xl("xl('Sheet2!B2', headers=True)", |_| Some("'Q4 2026'!B2".into())), Some("xl(\"'Q4 2026'!B2\", headers=True)".into()));
|
||||
assert_eq!(rewrite_xl("xl(\"A1\")", |_| Some("'Q\"4'!A1".into())), Some("xl(\"'Q\\\"4'!A1\")".into()));
|
||||
assert_eq!(rewrite_xl("xl('A1')", |_| Some("'Q\"4'!A1".into())), Some("xl('\\'Q\"4\\'!A1')".into()));
|
||||
for (delim, new) in [("'", "'Q4 2026'!B2"), ("'", "'Q\"4'!A1"), ("\"", "'Q\"4'!A1"), ("\"", "'a\\b'!A1"), ("'", "B2")] {
|
||||
let code = format!("x = xl({delim}A1{delim}) + 1");
|
||||
let out = rewrite_xl(&code, |_| Some(new.to_string())).unwrap();
|
||||
assert_eq!(xl_calls(&out)[0].reference.as_deref(), Some(new), "{out}");
|
||||
assert!(out.ends_with(") + 1"), "{out}");
|
||||
}
|
||||
assert_eq!(rewrite_xl("xl('A1')", |_| Some("A\n1".into())), None, "a line break can't go in a literal");
|
||||
// Escaped quotes and backslashes read as the characters; any other escape is not plain.
|
||||
assert_eq!(xl_calls("xl('Bob\\'s!A1')")[0].reference.as_deref(), Some("Bob's!A1"));
|
||||
assert_eq!(xl_calls("xl(\"A\\\\1\")")[0].reference.as_deref(), Some("A\\1"));
|
||||
assert_eq!(xl_calls("xl(\"A\\n1\")")[0].reference, None);
|
||||
assert_eq!(xl_calls("xl(\"A1\\\")")[0].reference, None, "an escaped closing quote leaves the literal open");
|
||||
// Unclosed and odd input doesn't loop or panic.
|
||||
for odd in ["xl(", "xl(\"", "xl('''A1''')", "x", "xl", "\"unterminated", "xl ( \"A1\" )", "é xl('A1')"] {
|
||||
let _ = xl_calls(odd);
|
||||
let _ = rewrite_xl(odd, |_| Some("B2".into()));
|
||||
}
|
||||
assert_eq!(xl_calls("xl ( \"A1\" )")[0].reference.as_deref(), Some("A1"));
|
||||
assert_eq!(xl_calls("xl('''A1''')")[0].reference, None);
|
||||
}
|
||||
}
|
||||
+271
-4
@@ -2,14 +2,15 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef, Value};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::cell::{Cell, CellPicture};
|
||||
use crate::features::{
|
||||
AutoFilter, Chart, Comment, CondFormat, Hyperlink, Image, LineInfo, PivotTable, PrintSettings, Shape, SheetProtection, Slicer, Sparkline, Table,
|
||||
Validation,
|
||||
Anchor, AutoFilter, Chart, Comment, CondFormat, Hyperlink, Image, LineInfo, ObjKind, ObjRef, PivotTable, PrintSettings, Shape, ShapeKind,
|
||||
SheetProtection, Slicer, Sparkline, Table, Validation,
|
||||
};
|
||||
use crate::store::CellStore;
|
||||
use crate::style::{Color, StyleId};
|
||||
@@ -52,9 +53,44 @@ impl SheetViewMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// A sheet's identity while the app runs, for what must follow one sheet (an open cell draft): a
|
||||
/// sheet made new (added, inserted, copied, read from a file or from JSON) gets a fresh one, and it
|
||||
/// stays with the sheet through renames, moves and the copies undo keeps. Never saved.
|
||||
/// It is not content: any two compare equal, so comparing sheets compares what they hold; compare
|
||||
/// identities with [`SheetUid::get`].
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct SheetUid(u64);
|
||||
|
||||
/// The next [`SheetUid`]; 0 is never given out.
|
||||
static NEXT_SHEET_UID: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
impl SheetUid {
|
||||
pub fn fresh() -> SheetUid {
|
||||
SheetUid(NEXT_SHEET_UID.fetch_add(1, Ordering::Relaxed))
|
||||
}
|
||||
pub fn get(self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SheetUid {
|
||||
fn default() -> SheetUid {
|
||||
SheetUid::fresh()
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for SheetUid {
|
||||
fn eq(&self, _: &SheetUid) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Sheet {
|
||||
pub name: String,
|
||||
/// The sheet's identity while the app runs ([`SheetUid`]). Not in JSON or the file.
|
||||
#[serde(skip)]
|
||||
pub uid: SheetUid,
|
||||
/// The `sheetId` the file gave the sheet, written back on save so that what refers to the
|
||||
/// sheet by its id keeps finding it; 0 for a sheet the file did not have. Not in JSON.
|
||||
#[serde(skip)]
|
||||
@@ -149,6 +185,7 @@ impl Sheet {
|
||||
pub fn new(name: impl Into<String>) -> Sheet {
|
||||
Sheet {
|
||||
name: name.into(),
|
||||
uid: SheetUid::fresh(),
|
||||
file_id: 0,
|
||||
cells: CellStore::new(),
|
||||
cell_pictures: BTreeMap::new(),
|
||||
@@ -202,6 +239,121 @@ impl Sheet {
|
||||
self.spill_ranges.iter().find(|(anchor, range)| **anchor != c && range.contains(c)).map(|(anchor, _)| *anchor)
|
||||
}
|
||||
|
||||
/// Removes the spilled values inside `r`. The work is bounded by the values stored, not by
|
||||
/// the size of `r`, so a range from a file that claims the whole sheet costs no more than the
|
||||
/// values it holds.
|
||||
pub fn clear_spill(&mut self, r: RangeRef) {
|
||||
if r.count() <= self.spill.len() as u64 {
|
||||
for c in r.iter() {
|
||||
self.spill.remove(&c);
|
||||
}
|
||||
} else {
|
||||
self.spill.retain(|c, _| !r.contains(*c));
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns the spill anchored at `anchor` into plain values, for a Python cell that keeps only
|
||||
/// its value: the range is forgotten, and each spilled value in it becomes its cell's value,
|
||||
/// unless the cell holds content of its own (its format is kept either way). The work is
|
||||
/// bounded by the spilled values stored, not by the size of the range.
|
||||
pub fn settle_spill(&mut self, anchor: CellRef) {
|
||||
let Some(r) = self.spill_ranges.remove(&anchor) else { return };
|
||||
let inside: Vec<CellRef> = self.spill.range(r.start..=r.end).map(|(c, _)| *c).filter(|c| r.contains(*c)).collect();
|
||||
for c in inside {
|
||||
let Some(v) = self.spill.remove(&c) else { continue };
|
||||
if v.is_empty() || c == anchor {
|
||||
continue;
|
||||
}
|
||||
match self.cells.get_mut(c) {
|
||||
Some(cell) if cell.has_content() => {}
|
||||
Some(cell) => cell.value = v,
|
||||
None => {
|
||||
let style = self.style_id(c);
|
||||
self.cells.set(c, Cell::styled(v, style));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The floating object with workbook-unique id `id` on this sheet.
|
||||
pub fn object_ref(&self, id: u32) -> Option<ObjRef> {
|
||||
let kind = if self.charts.iter().any(|o| o.id == id) {
|
||||
ObjKind::Chart
|
||||
} else if self.images.iter().any(|o| o.id == id) {
|
||||
ObjKind::Image
|
||||
} else if let Some(s) = self.shapes.iter().find(|o| o.id == id) {
|
||||
if s.kind == ShapeKind::Equation { ObjKind::Equation } else { ObjKind::Shape }
|
||||
} else if self.slicers.iter().any(|o| o.id == id) {
|
||||
ObjKind::Slicer
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some(ObjRef::new(kind, id))
|
||||
}
|
||||
|
||||
/// Whether the floating object `o` exists on this sheet. `Shape` accepts any shape (an
|
||||
/// equation included); `Equation` only an equation.
|
||||
pub fn has_object(&self, o: ObjRef) -> bool {
|
||||
self.object_anchor(o).is_some()
|
||||
}
|
||||
|
||||
/// The anchor of the floating object `o`.
|
||||
pub fn object_anchor(&self, o: ObjRef) -> Option<Anchor> {
|
||||
match o.kind {
|
||||
ObjKind::Chart => self.charts.iter().find(|c| c.id == o.id).map(|c| c.anchor),
|
||||
ObjKind::Image => self.images.iter().find(|c| c.id == o.id).map(|c| c.anchor),
|
||||
ObjKind::Shape => self.shapes.iter().find(|c| c.id == o.id).map(|c| c.anchor),
|
||||
ObjKind::Slicer => self.slicers.iter().find(|c| c.id == o.id).map(|c| c.anchor),
|
||||
ObjKind::Equation => self.shapes.iter().find(|c| c.id == o.id && c.kind == ShapeKind::Equation).map(|c| c.anchor),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn object_anchor_mut(&mut self, o: ObjRef) -> Option<&mut Anchor> {
|
||||
match o.kind {
|
||||
ObjKind::Chart => self.charts.iter_mut().find(|c| c.id == o.id).map(|c| &mut c.anchor),
|
||||
ObjKind::Image => self.images.iter_mut().find(|c| c.id == o.id).map(|c| &mut c.anchor),
|
||||
ObjKind::Shape => self.shapes.iter_mut().find(|c| c.id == o.id).map(|c| &mut c.anchor),
|
||||
ObjKind::Slicer => self.slicers.iter_mut().find(|c| c.id == o.id).map(|c| &mut c.anchor),
|
||||
ObjKind::Equation => self.shapes.iter_mut().find(|c| c.id == o.id && c.kind == ShapeKind::Equation).map(|c| &mut c.anchor),
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the floating object `o`; false when there is no such object.
|
||||
pub fn remove_object(&mut self, o: ObjRef) -> bool {
|
||||
fn take<T>(v: &mut Vec<T>, f: impl Fn(&T) -> bool) -> bool {
|
||||
let n = v.len();
|
||||
v.retain(|x| !f(x));
|
||||
v.len() != n
|
||||
}
|
||||
match o.kind {
|
||||
ObjKind::Chart => take(&mut self.charts, |c| c.id == o.id),
|
||||
ObjKind::Image => take(&mut self.images, |c| c.id == o.id),
|
||||
ObjKind::Shape => take(&mut self.shapes, |c| c.id == o.id),
|
||||
ObjKind::Slicer => take(&mut self.slicers, |c| c.id == o.id),
|
||||
ObjKind::Equation => take(&mut self.shapes, |c| c.id == o.id && c.kind == ShapeKind::Equation),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every floating object's anchor in a fixed order (charts, images, shapes, slicers) that
|
||||
/// [`Sheet::object_anchors_mut`] repeats, so a pass can compute new anchors from the old ones
|
||||
/// and write them back in step.
|
||||
pub fn object_anchors(&self) -> impl Iterator<Item = &Anchor> {
|
||||
self.charts
|
||||
.iter()
|
||||
.map(|o| &o.anchor)
|
||||
.chain(self.images.iter().map(|o| &o.anchor))
|
||||
.chain(self.shapes.iter().map(|o| &o.anchor))
|
||||
.chain(self.slicers.iter().map(|o| &o.anchor))
|
||||
}
|
||||
|
||||
pub fn object_anchors_mut(&mut self) -> impl Iterator<Item = &mut Anchor> {
|
||||
self.charts
|
||||
.iter_mut()
|
||||
.map(|o| &mut o.anchor)
|
||||
.chain(self.images.iter_mut().map(|o| &mut o.anchor))
|
||||
.chain(self.shapes.iter_mut().map(|o| &mut o.anchor))
|
||||
.chain(self.slicers.iter_mut().map(|o| &mut o.anchor))
|
||||
}
|
||||
/// Displayed value of a cell: its own value, or a spilled value.
|
||||
pub fn value(&self, c: CellRef) -> Value {
|
||||
match self.cells.get(c) {
|
||||
@@ -259,7 +411,7 @@ impl Sheet {
|
||||
}
|
||||
pub fn set_value(&mut self, c: CellRef, v: Value) {
|
||||
let style = self.style_id(c);
|
||||
self.set_cell(c, Cell { value: v, formula: None, style });
|
||||
self.set_cell(c, Cell { value: v, formula: None, style, py: None });
|
||||
}
|
||||
/// Replaces content, removing any picture at this address. Style-only edits use set_style.
|
||||
pub fn set_cell(&mut self, c: CellRef, cell: Cell) {
|
||||
@@ -278,7 +430,7 @@ impl Sheet {
|
||||
/// Pictures retain a scalar fallback for calculation and unsupported consumers.
|
||||
pub fn set_picture(&mut self, c: CellRef, picture: Arc<CellPicture>) {
|
||||
let style = self.style_id(c);
|
||||
self.cells.set(c, Cell { value: Value::Error(gridcraft_core::CellError::Value), formula: None, style });
|
||||
self.cells.set(c, Cell { value: Value::Error(gridcraft_core::CellError::Value), formula: None, style, py: None });
|
||||
self.cell_pictures.insert(c, picture);
|
||||
}
|
||||
pub fn input_text(&self, c: CellRef) -> String {
|
||||
@@ -478,6 +630,26 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::cell::Formula;
|
||||
|
||||
#[test]
|
||||
fn a_sheet_keeps_its_identity_through_clones_and_a_new_sheet_gets_its_own() {
|
||||
let a = Sheet::new("A");
|
||||
let b = Sheet::new("A");
|
||||
assert_ne!(a.uid.get(), b.uid.get(), "two new sheets");
|
||||
assert_ne!(a.uid.get(), 0);
|
||||
// A clone (an undo snapshot, a rename through Arc::make_mut) is the same sheet.
|
||||
let mut renamed = a.clone();
|
||||
renamed.name = "B".into();
|
||||
assert_eq!(renamed.uid.get(), a.uid.get());
|
||||
// Not content: equal sheets compare equal whatever their identity.
|
||||
assert_eq!(a, b);
|
||||
// Not in JSON: a sheet read back is a new one.
|
||||
let json = serde_json::to_string(&a).unwrap();
|
||||
assert!(!json.contains("uid"), "{json}");
|
||||
let read: Sheet = serde_json::from_str(&json).unwrap();
|
||||
assert_ne!(read.uid.get(), a.uid.get());
|
||||
assert_eq!(read, a);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_index_matches_linear() {
|
||||
let mut s = Sheet::new("S");
|
||||
@@ -626,6 +798,101 @@ mod tests {
|
||||
assert_eq!(s.col_at(-5.0), 0);
|
||||
assert_eq!(s.col_left(2), 128.0);
|
||||
}
|
||||
|
||||
use crate::features::{ChartKind, EquationCache, LegendPos, ShapeFormat, ShapeKind};
|
||||
|
||||
/// Adds a floating object of `kind` with id `id` at `anchor`. The match is exhaustive on
|
||||
/// purpose: a new kind of floating object does not compile here until this test covers it,
|
||||
/// and the counts in `object_anchor_accessors_visit_the_same_objects_in_the_same_order`
|
||||
/// then catch an accessor that was not taught about it. An equation is a shape of
|
||||
/// `ShapeKind::Equation`, so it lives in `shapes` and is visited among them.
|
||||
fn push_object(s: &mut Sheet, kind: ObjKind, id: u32, anchor: Anchor) {
|
||||
match kind {
|
||||
ObjKind::Chart => s.charts.push(Chart {
|
||||
id,
|
||||
kind: ChartKind::default(),
|
||||
anchor,
|
||||
title: None,
|
||||
series: vec![],
|
||||
legend: LegendPos::default(),
|
||||
data_labels: false,
|
||||
data_table: None,
|
||||
gridlines: false,
|
||||
style: 0,
|
||||
x_title: None,
|
||||
y_title: None,
|
||||
source: None,
|
||||
by_rows: false,
|
||||
pivot: None,
|
||||
}),
|
||||
ObjKind::Image => s.images.push(Image { id, anchor, data: Arc::new(vec![]), mime: String::new(), alt: String::new() }),
|
||||
ObjKind::Shape | ObjKind::Equation => s.shapes.push(Shape {
|
||||
id,
|
||||
kind: if kind == ObjKind::Equation { ShapeKind::Equation } else { ShapeKind::default() },
|
||||
anchor,
|
||||
fill: Color::default(),
|
||||
line: Color::default(),
|
||||
text: String::new(),
|
||||
format: ShapeFormat::default(),
|
||||
math: EquationCache::default(),
|
||||
}),
|
||||
ObjKind::Slicer => {
|
||||
s.slicers.push(Slicer { id, table: String::new(), column: String::new(), caption: String::new(), anchor, selected: None, columns: 1 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sorting, and inserting or deleting rows and columns, read every anchor through
|
||||
/// `object_anchors`, compute the new ones and write them back through `object_anchors_mut`
|
||||
/// in step. The two must visit the same objects in the same order, or an object receives
|
||||
/// another object's anchor; a kind that reaches only one of them would break that silently.
|
||||
#[test]
|
||||
fn object_anchor_accessors_visit_the_same_objects_in_the_same_order() {
|
||||
let mut s = Sheet::new("S");
|
||||
// Two objects of every kind, inserted in a mixed order, with ids unrelated to the kind.
|
||||
// Each anchor cell's row carries the id, so an anchor read back names its object.
|
||||
let inserted = [
|
||||
(ObjKind::Slicer, 7),
|
||||
(ObjKind::Chart, 3),
|
||||
(ObjKind::Image, 11),
|
||||
(ObjKind::Shape, 2),
|
||||
(ObjKind::Chart, 9),
|
||||
(ObjKind::Slicer, 1),
|
||||
(ObjKind::Image, 4),
|
||||
(ObjKind::Equation, 6),
|
||||
(ObjKind::Shape, 12),
|
||||
(ObjKind::Equation, 8),
|
||||
];
|
||||
for &(kind, id) in &inserted {
|
||||
push_object(&mut s, kind, id, Anchor { cell: CellRef::new(id, 0), ..Default::default() });
|
||||
}
|
||||
let per_kind = s.charts.len() + s.images.len() + s.shapes.len() + s.slicers.len();
|
||||
assert_eq!(per_kind, inserted.len());
|
||||
assert_eq!(s.object_anchors().count(), per_kind, "object_anchors visits every collection");
|
||||
assert_eq!(s.object_anchors_mut().count(), per_kind, "object_anchors_mut visits every collection");
|
||||
// Mark each anchor with its position through the mutable accessor; the shared accessor
|
||||
// reads the marks back in the same sequence.
|
||||
for (i, a) in s.object_anchors_mut().enumerate() {
|
||||
a.dx = i as f32;
|
||||
}
|
||||
let marks: Vec<f32> = s.object_anchors().map(|a| a.dx).collect();
|
||||
assert_eq!(marks, (0..per_kind).map(|i| i as f32).collect::<Vec<f32>>());
|
||||
// The sequence is the documented one (charts, images, shapes, slicers, each in insertion
|
||||
// order; equations are shapes) and every object appears in it once.
|
||||
let collection = |k: ObjKind| if k == ObjKind::Equation { ObjKind::Shape } else { k };
|
||||
let documented: Vec<u32> = [ObjKind::Chart, ObjKind::Image, ObjKind::Shape, ObjKind::Slicer]
|
||||
.iter()
|
||||
.flat_map(|&k| inserted.iter().filter(move |(kind, _)| collection(*kind) == k).map(|&(_, id)| id))
|
||||
.collect();
|
||||
let visited: Vec<u32> = s.object_anchors().map(|a| a.cell.row).collect();
|
||||
assert_eq!(visited, documented);
|
||||
// The mark written through the bulk accessor landed on the object `object_anchor` finds
|
||||
// by kind and id.
|
||||
for &(kind, id) in &inserted {
|
||||
let position = documented.iter().position(|&x| x == id).map(|p| p as f32);
|
||||
assert_eq!(s.object_anchor(ObjRef::new(kind, id)).map(|a| a.dx), position, "{kind:?} {id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CellRef is a struct, so encode the sparse map as entries for JSON as well as other formats.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user