mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-03 08:51:30 -06:00
Compare commits
561
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc1bc1f474 | ||
|
|
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 | ||
|
|
30b210b4ef | ||
|
|
29b17a6ff8 | ||
|
|
8cd1be510c | ||
|
|
ec7ee8d4ea | ||
|
|
e1e72426e5 | ||
|
|
dc095444f2 | ||
|
|
10b763e7a4 | ||
|
|
84d4f6aa84 | ||
|
|
e82e91b451 | ||
|
|
113aeff07c | ||
|
|
44e9ddad45 | ||
|
|
6917788124 | ||
|
|
ea1d52fe8f | ||
|
|
653227fe6e | ||
|
|
e129c072a1 | ||
|
|
bc173ead8f | ||
|
|
7f1574b573 | ||
|
|
6a3aa58a07 | ||
|
|
e6a8d4a8b6 | ||
|
|
49ed44cb02 | ||
|
|
229b6001ec | ||
|
|
1975360120 | ||
|
|
81ee96fa45 | ||
|
|
40d99e3068 | ||
|
|
4fbd1dc337 | ||
|
|
58aa00bae4 | ||
|
|
14ab0f8ee3 | ||
|
|
2d56f1b936 | ||
|
|
c03c61fd05 | ||
|
|
60a267b4ef | ||
|
|
5e415cdd34 | ||
|
|
a1e0383056 | ||
|
|
7ed8a9f312 | ||
|
|
a30644c872 | ||
|
|
1c7e9a2f90 | ||
|
|
15c9d8c10b | ||
|
|
2b4a9fc0f3 | ||
|
|
c55a7e35aa | ||
|
|
2c0a00fbae | ||
|
|
a85b1aea3a | ||
|
|
9276827b1a | ||
|
|
ea3460ea19 | ||
|
|
edcb626c3c | ||
|
|
f2fab9e161 | ||
|
|
1a06933410 | ||
|
|
aacdec4138 | ||
|
|
d25445f4e3 | ||
|
|
879ea7385f | ||
|
|
f2a75eca1b | ||
|
|
9654c2030a | ||
|
|
f2bef898e8 | ||
|
|
85bfe358dc | ||
|
|
72b1310a5a | ||
|
|
8fff311210 | ||
|
|
40b254c306 | ||
|
|
81aee9e075 | ||
|
|
67e39faeea | ||
|
|
59fd17ae56 | ||
|
|
8069ad33ef | ||
|
|
82a78a9d21 | ||
|
|
ac27e3da14 | ||
|
|
79fd7f456e | ||
|
|
590d5e4492 | ||
|
|
671e194bb9 | ||
|
|
513f19ded3 | ||
|
|
c5a80ff791 | ||
|
|
474472f999 | ||
|
|
2d5be2c57a | ||
|
|
6aa5a78828 | ||
|
|
e675efd307 | ||
|
|
981fad14e0 | ||
|
|
7fcf4232b7 | ||
|
|
8c0c2fc64b | ||
|
|
1dcff1742f | ||
|
|
79722c7666 | ||
|
|
2329904024 | ||
|
|
5f7137ca01 | ||
|
|
55850841d3 | ||
|
|
1f73f62c4f | ||
|
|
fb1e59ff61 | ||
|
|
8f59487cbe | ||
|
|
71d4e2311d | ||
|
|
213fe1150e | ||
|
|
0db7d97f2e | ||
|
|
4d1233d0a2 | ||
|
|
07c8201786 | ||
|
|
31d5cc5b41 | ||
|
|
c0fcc7de16 | ||
|
|
5390e230d8 | ||
|
|
c9eb797d8b | ||
|
|
5441691ca3 | ||
|
|
d3f54debd9 | ||
|
|
64ea0cb69c | ||
|
|
f887ecf1cf | ||
|
|
ae598a74fe | ||
|
|
bc0fffbe97 | ||
|
|
1ebef5a694 | ||
|
|
f812c53814 | ||
|
|
b775b3ce32 | ||
|
|
76aed738e3 | ||
|
|
a44d3d5ee5 | ||
|
|
2bf5dbac84 | ||
|
|
c269ff483d | ||
|
|
15f6297001 | ||
|
|
006b7962bd | ||
|
|
7ea3a4c2c3 | ||
|
|
cae0c9abdb | ||
|
|
ed81d7e34e | ||
|
|
8f7dff0d04 | ||
|
|
7b7e734524 | ||
|
|
6d5ab57f84 | ||
|
|
a04629576d | ||
|
|
3174020a70 | ||
|
|
781149a593 | ||
|
|
322298b202 | ||
|
|
11d6ff1b79 | ||
|
|
eefa7c4a54 | ||
|
|
45939a8a41 | ||
|
|
70da8bb3ae | ||
|
|
a0da53f576 | ||
|
|
ca44a5d68f | ||
|
|
3b61d660e9 | ||
|
|
cca8a58bfa | ||
|
|
d3cd393dba | ||
|
|
8985685833 | ||
|
|
962a5df841 | ||
|
|
fea4c03ca2 | ||
|
|
1d9096e5ec | ||
|
|
8e740d97e1 | ||
|
|
a9bacdca4c | ||
|
|
53d6de3c16 | ||
|
|
6aa2cb8223 | ||
|
|
83e6408b71 | ||
|
|
1b1224c67a | ||
|
|
213b76af10 | ||
|
|
4e5c86d842 | ||
|
|
ad69d4cbdd | ||
|
|
32dbf6a224 | ||
|
|
11b54ebe40 | ||
|
|
9e710199b1 | ||
|
|
5ee65a8005 | ||
|
|
48c6b57060 | ||
|
|
ec2efe3246 | ||
|
|
5ed6a1b9c7 | ||
|
|
5d89b9efdf | ||
|
|
f1ff38504a | ||
|
|
0435ff50f9 | ||
|
|
550085f8b8 | ||
|
|
3a27743a38 | ||
|
|
dc691d52db | ||
|
|
bd8cf4e14a | ||
|
|
dc75555ee9 | ||
|
|
4d725e6bc1 | ||
|
|
9fccec28af | ||
|
|
52d317c8ff | ||
|
|
00359ee93a | ||
|
|
c895acf1f7 | ||
|
|
1f4c67ef0a | ||
|
|
8cf8425745 | ||
|
|
59a8b14d4d | ||
|
|
8f93ba2e9c | ||
|
|
3de6cb1a3d | ||
|
|
75d4de230b | ||
|
|
fbff1e301c | ||
|
|
5c31d7a17e | ||
|
|
22b6dfe8d7 | ||
|
|
b612141726 | ||
|
|
c82a138462 | ||
|
|
591c6ed6f9 | ||
|
|
9587f08253 | ||
|
|
75f7056e1b | ||
|
|
d0cc17852e | ||
|
|
d43baa2ceb | ||
|
|
15ea238fb7 | ||
|
|
b2f984a884 | ||
|
|
4afcaacd44 | ||
|
|
885a5dd6bc | ||
|
|
39020af9e9 | ||
|
|
7abaeba33c | ||
|
|
ecd72eccc4 | ||
|
|
ea0da03e12 | ||
|
|
569fb8b4c6 | ||
|
|
dec916081a | ||
|
|
da3e38750a | ||
|
|
3e4a2ccc9a | ||
|
|
27d5930d6b | ||
|
|
412571bc28 | ||
|
|
6f77f68fbc | ||
|
|
c305315610 | ||
|
|
e4d8aae522 | ||
|
|
92256075f2 | ||
|
|
9e2d5ef17f | ||
|
|
559a0369c3 | ||
|
|
1d38a25c8d | ||
|
|
6f067cce8a | ||
|
|
68a4b1a1c3 | ||
|
|
f6bcd77b01 | ||
|
|
400e5acd6c | ||
|
|
7d38697f71 | ||
|
|
39a10b1342 | ||
|
|
57e40d2d14 | ||
|
|
6ba1e19857 | ||
|
|
9c6de8abde | ||
|
|
0688e83752 | ||
|
|
b004335538 | ||
|
|
45cc92ae3c | ||
|
|
94433a3c4f | ||
|
|
2ff751791b | ||
|
|
9e0f14cab9 | ||
|
|
355840a2a2 | ||
|
|
bc1e19f1f6 | ||
|
|
d1f063636a | ||
|
|
6af06e9d30 | ||
|
|
f6b900dd5a | ||
|
|
3fc35e5d3b | ||
|
|
3923ab3dca | ||
|
|
70bcab56e2 | ||
|
|
708481b615 | ||
|
|
a1a491b10c | ||
|
|
7bf8ed7495 | ||
|
|
9e23c98f3a | ||
|
|
f01c02cc41 | ||
|
|
146161b4b4 | ||
|
|
70327cd3d9 | ||
|
|
f9b869b3a4 | ||
|
|
44c3a36ea1 | ||
|
|
c22f46628c | ||
|
|
dd48d09605 | ||
|
|
a81cb0f21e | ||
|
|
712f45965b | ||
|
|
ff7ed4b57c | ||
|
|
3b73ac9b91 | ||
|
|
f6015a7e6e | ||
|
|
87342cb308 | ||
|
|
0f78a6b126 | ||
|
|
28a8e67e7a | ||
|
|
25816f0009 | ||
|
|
bf4a3969e8 | ||
|
|
f77d70502a | ||
|
|
6e7fb6ce0b | ||
|
|
8b35f2ac12 | ||
|
|
a8e03ef40f | ||
|
|
66f8b05fc2 | ||
|
|
0e643e9026 | ||
|
|
d3832e98e6 | ||
|
|
fa6ccc6ae5 | ||
|
|
a38aed68cb | ||
|
|
7d828c01f7 | ||
|
|
4cc2c44678 | ||
|
|
ea5292c94a | ||
|
|
0edf2b850a | ||
|
|
2b6e088a6d | ||
|
|
acc32227cb | ||
|
|
1fadf98a10 | ||
|
|
f4f48f3b23 | ||
|
|
9595fc8ee7 | ||
|
|
9515c29969 | ||
|
|
c72f5a49b0 | ||
|
|
5ed5c09bc6 | ||
|
|
a75b19e678 | ||
|
|
551ec6590d | ||
|
|
9cc126409b | ||
|
|
1d7206ebdd | ||
|
|
fcbbfa5142 | ||
|
|
c6c54c57e6 | ||
|
|
44c663aea2 | ||
|
|
8cfe970ff8 | ||
|
|
e40291fc3d | ||
|
|
c801e50e40 | ||
|
|
f8e1f8dfc0 | ||
|
|
4d26e395fc | ||
|
|
d4096eee5a | ||
|
|
34eee86053 | ||
|
|
6781aa4927 | ||
|
|
483276f719 | ||
|
|
3772f2c61a | ||
|
|
1cd45cc909 | ||
|
|
e85297e26c | ||
|
|
b552b7a929 | ||
|
|
910b50c1dd | ||
|
|
848b0e658a | ||
|
|
0cb1745597 | ||
|
|
8fd6ec124b | ||
|
|
299beb5085 | ||
|
|
ffacf7405f | ||
|
|
59d85ca138 | ||
|
|
ea5eb2ae3c | ||
|
|
c442da9221 | ||
|
|
c142c70dbe | ||
|
|
1e2492b4d6 | ||
|
|
60c20095e6 | ||
|
|
a414b608d7 | ||
|
|
a0fd599fe6 | ||
|
|
0fdb4d03e4 | ||
|
|
baf01c4211 | ||
|
|
f73e7b7e19 | ||
|
|
12072c5529 | ||
|
|
a18ecc81aa | ||
|
|
a206178209 | ||
|
|
73b0edd085 | ||
|
|
aa86b0c327 | ||
|
|
cec19d6af6 | ||
|
|
3d6a09ec75 | ||
|
|
b9a8fa304c | ||
|
|
c523a4c48a | ||
|
|
eb7b2b93f4 | ||
|
|
bca9a10f8a | ||
|
|
1443ec417c | ||
|
|
5512c1a269 | ||
|
|
7c66aaa2d3 | ||
|
|
bf6a8520b8 | ||
|
|
65634c2bb7 | ||
|
|
0533480c97 | ||
|
|
b9aa4a6a9c | ||
|
|
ca2be34e8a | ||
|
|
9a22c4b2f2 | ||
|
|
24f0c3df92 | ||
|
|
1a00ce19fb | ||
|
|
0a9305f784 | ||
|
|
0a929eaf68 | ||
|
|
2e8284c191 | ||
|
|
f0b667aa02 | ||
|
|
ec58ec5f4b | ||
|
|
361fff982a | ||
|
|
fe14c0d86e | ||
|
|
891986bf66 | ||
|
|
0aae1ffeea | ||
|
|
bfcf50aad3 | ||
|
|
f225cd1026 | ||
|
|
9fe25835d4 | ||
|
|
f6f4585c93 | ||
|
|
91bb5eded8 | ||
|
|
5aa10132aa | ||
|
|
267d5bc503 | ||
|
|
90a46da496 | ||
|
|
00b22d5e33 | ||
|
|
fdd26876fb | ||
|
|
227078f56a | ||
|
|
dadffdb33f | ||
|
|
e9effe5dbd | ||
|
|
198fe482a1 | ||
|
|
43134b308e | ||
|
|
460a72d3bb | ||
|
|
8b018d89cd | ||
|
|
bc0d3035fb | ||
|
|
f66ebd6fb2 | ||
|
|
7dad14e77e | ||
|
|
d309c85c31 | ||
|
|
60e0039524 | ||
|
|
ce2ab9a473 | ||
|
|
c715c36c07 | ||
|
|
2cf5b808b3 | ||
|
|
3708c80168 | ||
|
|
567ed0abc9 | ||
|
|
65cbcdd6bd | ||
|
|
ad79b071ce | ||
|
|
d053b1f548 | ||
|
|
0719458114 | ||
|
|
5860857937 | ||
|
|
b40fa4e065 | ||
|
|
2fb31f11f2 | ||
|
|
8ea4e27918 | ||
|
|
63a29800a3 | ||
|
|
b995c7c9a0 | ||
|
|
1e34a76428 | ||
|
|
c225caf30a | ||
|
|
9331b05f62 | ||
|
|
9fdf03cfb5 | ||
|
|
3f8e6c2884 | ||
|
|
e88760b480 | ||
|
|
e285194643 | ||
|
|
4c11929fe2 | ||
|
|
da439b71ed | ||
|
|
3905a4318e | ||
|
|
471a40bd72 | ||
|
|
ac36eee23d | ||
|
|
3c722f4d86 | ||
|
|
9ed1001035 | ||
|
|
bf85ba482c | ||
|
|
b36a3a9b8b | ||
|
|
54bd4eca9d | ||
|
|
6cd4cebfb7 | ||
|
|
f040b4ecaa | ||
|
|
fddf2d9a31 | ||
|
|
95c7e70ce6 | ||
|
|
3b25aa0d16 | ||
|
|
4a294aefa9 | ||
|
|
b09a5502e3 | ||
|
|
b44a5139ab | ||
|
|
f2a7252f93 | ||
|
|
9bbba198c6 | ||
|
|
37bf09e94a | ||
|
|
4668fc30ae | ||
|
|
b90710794a | ||
|
|
0fa6e78863 | ||
|
|
2e72407fc6 | ||
|
|
2b33fcf881 | ||
|
|
359cce4bc3 | ||
|
|
ee8e5d35c0 | ||
|
|
bac5b9c66e | ||
|
|
bb3db53489 | ||
|
|
2215934c9d | ||
|
|
879b96f858 | ||
|
|
a626705c95 | ||
|
|
fb4acb04a2 | ||
|
|
74ff8ac625 | ||
|
|
461395d0a0 | ||
|
|
551eda7e8e | ||
|
|
43b0e39e8e | ||
|
|
bbf36000d4 | ||
|
|
0df540f166 | ||
|
|
44a2f4e2f1 | ||
|
|
00b63468e6 | ||
|
|
673af7ecdc | ||
|
|
4672b6da51 | ||
|
|
712bb766ec | ||
|
|
8219c18b92 | ||
|
|
259b6e67e8 | ||
|
|
7ee9d77483 | ||
|
|
3ca1252571 | ||
|
|
378ce3a5a0 | ||
|
|
cef58da39e | ||
|
|
d7c3c18345 | ||
|
|
8440427c53 | ||
|
|
d1e51a4af4 | ||
|
|
456cf73886 | ||
|
|
a3b5f8e45c | ||
|
|
6b7c48f119 | ||
|
|
66e69cb946 | ||
|
|
ad2e3ac9de | ||
|
|
2c052d03d5 | ||
|
|
7e383f460a | ||
|
|
080b6d993b | ||
|
|
6ab8d97118 | ||
|
|
114088d589 | ||
|
|
5ca886507c | ||
|
|
524826687c | ||
|
|
a8e8bf851e | ||
|
|
18ec913c4e | ||
|
|
602d4450a2 | ||
|
|
67fa9f7b4e | ||
|
|
bca1483e2e | ||
|
|
38ad2dba83 | ||
|
|
3eed81318f | ||
|
|
faff24f3aa | ||
|
|
66f7993ba5 | ||
|
|
bbf1bc441b | ||
|
|
ea691f703c | ||
|
|
3f5f6ea4f1 | ||
|
|
20eb282e75 | ||
|
|
6a004bf610 | ||
|
|
c0079aabe9 | ||
|
|
045cf428f5 | ||
|
|
6312c7cb40 | ||
|
|
8797b7183c | ||
|
|
f553619b33 | ||
|
|
f1a46a2b53 | ||
|
|
0ea5d3ee33 | ||
|
|
3c74dfcbb0 | ||
|
|
efce63f75f | ||
|
|
62cececa63 | ||
|
|
06bbcb504a | ||
|
|
77150be455 | ||
|
|
7d31c5a4ed | ||
|
|
3f9223c85f | ||
|
|
ccea7a8db3 | ||
|
|
b51d588cac | ||
|
|
ac7c1ae639 | ||
|
|
ee7040a340 | ||
|
|
c6dda8562f | ||
|
|
45e580ad91 | ||
|
|
bf456c9ff8 | ||
|
|
7a0f591712 | ||
|
|
709a995290 | ||
|
|
252ffa95b6 | ||
|
|
4c6962a97c | ||
|
|
3bc1d91636 | ||
|
|
4966b9c86d | ||
|
|
749558f978 | ||
|
|
d305566e57 | ||
|
|
7011395fcf | ||
|
|
b2740bdf33 | ||
|
|
23c0107511 | ||
|
|
20d2a3b998 | ||
|
|
54c08645b9 | ||
|
|
a4b4872602 | ||
|
|
471114e296 | ||
|
|
2287f63a95 | ||
|
|
eb3eaa052d | ||
|
|
0457f102a0 | ||
|
|
ab27d35386 | ||
|
|
f4e05534a3 | ||
|
|
83ae63f793 | ||
|
|
e165eaf3a6 | ||
|
|
7d1fee8c78 | ||
|
|
b7bce3bc09 | ||
|
|
d4f1e61c99 | ||
|
|
acfd38f309 | ||
|
|
c01e46619a | ||
|
|
9d9911dca1 | ||
|
|
d280e6ff00 |
@@ -12,6 +12,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
|
||||
@@ -19,8 +19,8 @@ permissions:
|
||||
|
||||
jobs:
|
||||
windows-arm64:
|
||||
# Immutable supporting workflow revision until the new install contract lands upstream.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
|
||||
with:
|
||||
app-name: gridcraft
|
||||
app-dir: GridCraft
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
GridCraft is a clean-room, open-source, Rust-native spreadsheet targeting Microsoft Excel parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../designcraft` (InDesign-class, the closest reference), `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../effectcraft` (After Effects).
|
||||
|
||||
Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out next to the craft apps, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/excel/*.md` (`observed-ui.md` holds measured observations of the running app).
|
||||
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
|
||||
@@ -20,7 +18,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule
|
||||
- The ArtCraft brand files in `docs/brand/` are ArtCraft trademarks under `docs/brand/LICENSE-brand.txt`, the one exception.
|
||||
|
||||
## Never crash
|
||||
People trust GridCraft with their numbers; a crash loses their work. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
|
||||
People trust GridCraft with their numbers; a crash loses their work. **This outranks feature work.**
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`.
|
||||
- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): validate indexes and ranges before access, use checked access when validity is not established, use checked/saturating arithmetic, and cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references).
|
||||
@@ -31,8 +29,8 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
||||
|
||||
## Non-negotiables
|
||||
- **Clean-room.** Microsoft Excel is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic workbooks, take screenshots (by window id) stored only under `plan/excel/screenshots/`. Never read, disassemble or copy anything inside the Office bundle, never commit files produced by Excel, never copy GPL/AGPL/LGPL code (LibreOffice, Gnumeric…). Function behaviour comes from public documentation and observation.
|
||||
- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`); without it the app uses system fonts and egui's defaults. Standard: craftrules `standards/fonts.md`.
|
||||
- **Test corpora** (real Excel-authored files) live in a separate corpus repo, fetched pinned and sha256-verified (craftrules `standards/test-corpora.md`). Never commit large binary fixtures; build test workbooks in code.
|
||||
- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`); without it the app uses system fonts and egui's defaults.
|
||||
- **Test corpora** (real Excel-authored files) live in a separate corpus repo, fetched pinned and sha256-verified. Never commit large binary fixtures; build test workbooks in code.
|
||||
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, ribbon path, shortcut, params doc, `enabled`, `run`) + tests. Ids follow Excel's ribbon (`home.bold`, `insert.chart`, `data.sortAscending`) plus primitives (`cell.set`, `selection.set`, `range.setValues`). UI-only commands live in `crates/ui-egui`. The control channel, CLI and MCP reach all of them.
|
||||
- **Programmatic calls never open dialogs**: a command with empty params runs with defaults or returns an error; only menu-style invocation opens a dialog (`UiRequest::Dialog`).
|
||||
- **Layering** is enforced by `cargo xtask layers`: `core`/`numfmt` (L0) → `formula`/`functions` (L1) → `model`/`calc`/`xlsx` (L2) → `chart` (L3) → `engine` (L5) → `ui-egui`/`mcp` (L6) → `apps/*` (L7). Nothing below L6 depends on egui/eframe/winit/rfd. **The UI crate is swappable.**
|
||||
@@ -51,7 +49,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done.
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It follows craftrules `standards/progress-docs.md` and points at the detail in `docs/`: `target-app-parity.md` (authoritative assessment), `gaps.md` (ranked work list: pick from it), `roadmap.md` (current focus, beta gates, milestones), `architecture.md`, and the area checklists `function-parity.md`, `chart-parity.md`, `file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`. Update the affected docs (and their timestamp lines and revision history) whenever work lands. `cargo xtask parity` regenerates `docs/parity-checklist.md` from `crates/engine/src/catalog.rs`.
|
||||
`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It points at the detail in `docs/`: `target-app-parity.md` (authoritative assessment), `gaps.md` (ranked work list: pick from it), `roadmap.md` (current focus, beta gates, milestones), `architecture.md`, and the area checklists `function-parity.md`, `chart-parity.md`, `file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`. Update the affected docs (and their timestamp lines and revision history) whenever work lands. `cargo xtask parity` regenerates `docs/parity-checklist.md` from `crates/engine/src/catalog.rs`.
|
||||
|
||||
## Contributor credits (About window)
|
||||
|
||||
@@ -62,11 +60,13 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
||||
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
|
||||
whether they want to be credited by more than their username: a real name, a display name, and/or
|
||||
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
|
||||
(copy the template at the top of the file, or run
|
||||
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
|
||||
(copy the template at the top of the file)
|
||||
and include it in their PR, committed as them. If no, change nothing: they are credited as
|
||||
`@username` anyway.
|
||||
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
|
||||
+11
-9
@@ -12,15 +12,15 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
| `epaint_default_fonts::HACK_REGULAR` (dependency font input) | Source Foundry Authors (Hack; DejaVu and Bitstream Vera foundations) | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Hack](https://github.com/source-foundry/Hack) | MIT + Bitstream Vera License (full upstream texts in `NOTICE`) | Hack-Regular.ttf is egui's bundled monospace face and GridCraft's last-resort symbol fallback. Dependency asset unmodified here, no font file copied into this repository. |
|
||||
| `epaint_default_fonts::NOTO_EMOJI_REGULAR` (dependency font input) | The Noto Project Authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Noto Emoji](https://fonts.google.com/noto/specimen/Noto+Emoji) | SIL Open Font License 1.1 (full upstream text in `NOTICE`) | NotoEmoji-Regular.ttf is enabled by egui's `monochrome_emoji_fonts` feature and used as the monochrome emoji fallback. Dependency asset unmodified here, no font file copied into this repository. |
|
||||
| `epaint_default_fonts::UBUNTU_LIGHT` (dependency font input) | Canonical Ltd. and the Ubuntu Font Family authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Ubuntu font](https://design.ubuntu.com/font) | Ubuntu Font Licence 1.0 (full upstream text in `NOTICE`) | Ubuntu-Light.ttf is egui's default proportional face. Dependency asset unmodified here, no font file copied into this repository. |
|
||||
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-logo.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
|
||||
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
|
||||
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
|
||||
| `docs/brand/artcraft-mark.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
|
||||
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
|
||||
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms | Trademark |
|
||||
| `docs/brand/LICENSE-brand.txt` | (licence text) | craftrules | — | |
|
||||
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-logo.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark |
|
||||
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark |
|
||||
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark |
|
||||
| `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark |
|
||||
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark |
|
||||
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark |
|
||||
| `docs/brand/LICENSE-brand.txt` | (licence text) | project | — | |
|
||||
| `assets/app-icon/gridcraft.svg` | GridCraft contributors | original (hand-written SVG; `packaging/icons.sh`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | PLACEHOLDER app icon (ledger page); replaced by the owner's creature art later |
|
||||
| `packaging/macos/dmg/background.svg` | @XusBadia | original (hand-written SVG, derived from the GridCraft app icon `assets/app-icon/gridcraft.svg`, referenced via `<image>`, not copied; text is outlined glyph paths; `packaging/macos/dmg/generate.py` renders it to `background.tiff`) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | GridCraft macOS DMG window background for the Finder window; added 2026-10-08 |
|
||||
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") |
|
||||
@@ -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 |
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
GridCraft is a clean-room, open-source, Rust-native spreadsheet targeting Microsoft Excel parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and BSD, and on the web via WASM. Siblings with the same conventions: `../designcraft` (InDesign-class, the closest reference), `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../pdfcraft` (Acrobat), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../effectcraft` (After Effects).
|
||||
|
||||
Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out next to the craft apps, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/excel/*.md` (`observed-ui.md` holds measured observations of the running app).
|
||||
2. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
|
||||
@@ -20,7 +18,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule
|
||||
- The ArtCraft brand files in `docs/brand/` are ArtCraft trademarks under `docs/brand/LICENSE-brand.txt`, the one exception.
|
||||
|
||||
## Never crash
|
||||
People trust GridCraft with their numbers; a crash loses their work. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
|
||||
People trust GridCraft with their numbers; a crash loses their work. **This outranks feature work.**
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`.
|
||||
- **Input-derived numbers are hostile** (files, formulas, commands, MCP/control params): `get()` instead of indexing, checked/saturating arithmetic, cap input-sized allocations (huge ranges, `REPT`, `SEQUENCE`, whole-column references).
|
||||
@@ -31,8 +29,8 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
||||
|
||||
## Non-negotiables
|
||||
- **Clean-room.** Microsoft Excel is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic workbooks, take screenshots (by window id) stored only under `plan/excel/screenshots/`. Never read, disassemble or copy anything inside the Office bundle, never commit files produced by Excel, never copy GPL/AGPL/LGPL code (LibreOffice, Gnumeric…). Function behaviour comes from public documentation and observation.
|
||||
- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`); without it the app uses system fonts and egui's defaults. Standard: craftrules `standards/fonts.md`.
|
||||
- **Test corpora** (real Excel-authored files) live in a separate corpus repo, fetched pinned and sha256-verified (craftrules `standards/test-corpora.md`). Never commit large binary fixtures; build test workbooks in code.
|
||||
- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`); without it the app uses system fonts and egui's defaults.
|
||||
- **Test corpora** (real Excel-authored files) live in a separate corpus repo, fetched pinned and sha256-verified. Never commit large binary fixtures; build test workbooks in code.
|
||||
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, ribbon path, shortcut, params doc, `enabled`, `run`) + tests. Ids follow Excel's ribbon (`home.bold`, `insert.chart`, `data.sortAscending`) plus primitives (`cell.set`, `selection.set`, `range.setValues`). UI-only commands live in `crates/ui-egui`. The control channel, CLI and MCP reach all of them.
|
||||
- **Programmatic calls never open dialogs**: a command with empty params runs with defaults or returns an error; only menu-style invocation opens a dialog (`UiRequest::Dialog`).
|
||||
- **Layering** is enforced by `cargo xtask layers`: `core`/`numfmt` (L0) → `formula`/`functions` (L1) → `model`/`calc`/`xlsx` (L2) → `chart` (L3) → `engine` (L5) → `ui-egui`/`mcp` (L6) → `apps/*` (L7). Nothing below L6 depends on egui/eframe/winit/rfd. **The UI crate is swappable.**
|
||||
@@ -50,4 +48,9 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done.
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It follows craftrules `standards/progress-docs.md` and points at the detail in `docs/`: `target-app-parity.md` (authoritative assessment), `gaps.md` (ranked work list: pick from it), `roadmap.md` (current focus, beta gates, milestones), `architecture.md`, and the area checklists `function-parity.md`, `chart-parity.md`, `file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`. Update the affected docs (and their timestamp lines and revision history) whenever work lands. `cargo xtask parity` regenerates `docs/parity-checklist.md` from `crates/engine/src/catalog.rs`.
|
||||
`ROADMAP.md` (committed) is the one-page summary: stage, headline numbers, dimensions, languages, progress log. It points at the detail in `docs/`: `target-app-parity.md` (authoritative assessment), `gaps.md` (ranked work list: pick from it), `roadmap.md` (current focus, beta gates, milestones), `architecture.md`, and the area checklists `function-parity.md`, `chart-parity.md`, `file-format-parity.md`, `ui-parity.md`, `hardware-parity.md`, `localization-parity.md`. Update the affected docs (and their timestamp lines and revision history) whenever work lands. `cargo xtask parity` regenerates `docs/parity-checklist.md` from `crates/engine/src/catalog.rs`.
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
|
||||
Generated
+259
-43
@@ -20,7 +20,7 @@ dependencies = [
|
||||
"accesskit",
|
||||
"accesskit_consumer",
|
||||
"atspi-common",
|
||||
"phf",
|
||||
"phf 0.14.0",
|
||||
"serde",
|
||||
"zvariant",
|
||||
]
|
||||
@@ -220,11 +220,17 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
"wl-clipboard-rs",
|
||||
"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"
|
||||
@@ -889,12 +910,12 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
@@ -917,9 +938,10 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"craft-winprint-gdi",
|
||||
"log",
|
||||
"miniz_oxide 0.9.1",
|
||||
"roxmltree",
|
||||
"tempfile",
|
||||
@@ -930,7 +952,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
@@ -950,13 +972,14 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
"egui",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"image",
|
||||
"log",
|
||||
"plist",
|
||||
"semver",
|
||||
@@ -975,7 +998,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"objc2 0.6.5",
|
||||
@@ -1455,7 +1478,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1493,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"
|
||||
@@ -1935,7 +1969,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft"
|
||||
version = "0.13.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -1962,7 +1996,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-calc"
|
||||
version = "0.13.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
@@ -1974,7 +2008,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-chart"
|
||||
version = "0.13.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"gridcraft-calc",
|
||||
"gridcraft-core",
|
||||
@@ -1985,7 +2019,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-cli"
|
||||
version = "0.13.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"gridcraft-mcp",
|
||||
@@ -1997,7 +2031,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-core"
|
||||
version = "0.13.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"js-sys",
|
||||
@@ -2008,13 +2042,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-engine"
|
||||
version = "0.13.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"gridcraft-calc",
|
||||
"gridcraft-chart",
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
"gridcraft-functions",
|
||||
"gridcraft-math",
|
||||
"gridcraft-model",
|
||||
"gridcraft-numfmt",
|
||||
"gridcraft-pdf",
|
||||
@@ -2030,7 +2065,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-formula"
|
||||
version = "0.13.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"serde",
|
||||
@@ -2039,15 +2074,25 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-functions"
|
||||
version = "0.13.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.13.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"log",
|
||||
@@ -2056,10 +2101,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-model"
|
||||
version = "0.13.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
"gridcraft-math",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.1",
|
||||
@@ -2068,18 +2114,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-numfmt"
|
||||
version = "0.13.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-pdf"
|
||||
version = "0.13.0"
|
||||
version = "0.16.0"
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-ui-egui"
|
||||
version = "0.13.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
@@ -2104,7 +2150,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-web"
|
||||
version = "0.13.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -2121,12 +2167,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-xlsx"
|
||||
version = "0.13.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",
|
||||
@@ -2965,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",
|
||||
@@ -3460,7 +3507,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3541,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"
|
||||
@@ -3559,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]]
|
||||
@@ -3568,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"
|
||||
@@ -3850,7 +3939,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand",
|
||||
"rand 0.10.3",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash 2.1.3",
|
||||
@@ -3874,7 +3963,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3898,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"
|
||||
@@ -3936,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"
|
||||
@@ -4035,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"
|
||||
@@ -4184,7 +4348,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4243,7 +4407,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4312,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"
|
||||
@@ -4615,7 +4790,7 @@ dependencies = [
|
||||
"const-oid",
|
||||
"der",
|
||||
"hex",
|
||||
"rand",
|
||||
"rand 0.10.3",
|
||||
"reqwest",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
@@ -4844,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"
|
||||
@@ -4977,7 +5158,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5073,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"
|
||||
@@ -5344,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"
|
||||
@@ -5784,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",
|
||||
@@ -5857,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",
|
||||
@@ -5936,7 +6151,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6183,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",
|
||||
@@ -6402,7 +6618,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.13.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"serde_json",
|
||||
|
||||
+12
-3
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.13.0"
|
||||
version = "0.16.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -24,9 +24,11 @@ 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 = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", features = ["egui"] }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", features = ["egui"] }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
@@ -46,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 }
|
||||
@@ -62,7 +71,7 @@ skrifa = { version = "0.44", default-features = false, features = ["std"] }
|
||||
epaint_default_fonts = "0.37"
|
||||
# One native window per workbook: the window list, Window menu titles and the immediate-viewport
|
||||
# driver. Same craft-libs revision as craft-update above.
|
||||
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", default-features = false, features = ["egui"] }
|
||||
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", default-features = false, features = ["egui"] }
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -298,7 +298,15 @@ stand on its own.
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | **Spreadsheets and data · you are here** | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
|
||||
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
|
||||
|
||||
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
|
||||
|
||||
|
||||
+34
-5
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** minor (AutoSum over a selected row puts its total to the right, and fills a selection's empty last row and column) · **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 274 / 290 (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) |
|
||||
@@ -86,7 +86,7 @@ collaboration, mobile, AI).
|
||||
| Get & Transform, external data, data model | ~15% | ~10% | 60–100 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
|
||||
| Insert objects (pictures, shapes, ink, SmartArt, equations) | ~55% | ~45% | 25–40 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
|
||||
| Review, comments, protection, collaboration | ~55% | ~40% | 35–55 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
|
||||
| Automation (recorded scripts; no VBA) | ~30% | ~20% | 12–20 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
|
||||
| Automation (recorded scripts; typed macros with a recorder, a Macros dialog and a step editor; no VBA) | ~30% | ~20% | 12–20 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) |
|
||||
|
||||
## Languages
|
||||
|
||||
@@ -135,7 +135,20 @@ 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) |
|
||||
| 2026-10-31 | Recently Used lists the functions most recently entered in a formula from any source (ribbon, control channel, MCP, scripts), kept with the preferences and returned by `formulas.recentlyUsed`; the Insert Function dialog takes Excel's title and a working category list (Most Recently Used, All, each category) |
|
||||
| 2026-10-31 | Formulas › Function Library as a big Insert Function button, the AutoSum split button and three columns of labelled small buttons, each category book in its own colour; category menus end with Insert Function… on that category; More Functions cascades into the remaining categories; Home › Editing shares the AutoSum menu; the MR key tip opens Recently Used |
|
||||
| 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before |
|
||||
| 2026-10-24 | Macro interface ([macros.md](docs/macros.md#in-the-window)): an Automate › Macros group (Record Macro, which turns into Stop Recording, Use Relative References, Run Macro with recent macros); "Recording · N steps" with Stop in the status bar; a Record Macro dialog (checked name, free Ctrl+Shift shortcut, description); the Macros dialog lists macros beside Automate scripts and a VBA project, with Run, Step Into, Edit, Create, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right; a step editor with a breakpoint gutter, drag to reorder, a command picker, reference boxes that switch between relative tokens and absolute references, Inputs and Output tabs, a debug bar (Check, Run, Step, Continue, Run to Here, Stop with keep or undo) and grid overlays for inputs, writes, conflicts and the current step |
|
||||
| 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) |
|
||||
| 2026-10-24 | Status bar: a right-click opens a menu that sets which selection statistics show (Average, Count and Sum by default; Numerical Count, Minimum and Maximum are new), kept with the preferences (`view.statusBar`) |
|
||||
| 2026-10-24 | Ctrl+Shift+U (Control+Shift+U on a Mac) expands and collapses the formula bar, on the grid and while editing (`view.expandFormulaBar`); on Windows and Linux it used to underline |
|
||||
@@ -166,6 +179,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-14 | Lookups (MATCH, XMATCH, VLOOKUP, HLOOKUP, XLOOKUP, LOOKUP) compare numbers exactly like Excel, so a computed key such as `=0.1+0.2` no longer finds 0.3; `=` and COUNTIF/SUMIF keep their 15-digit comparison |
|
||||
| 2026-10-14 | Functions: EOMONTH, EDATE, WORKDAY, NETWORKDAYS, YEARFRAC, WEEKNUM, the bond and coupon functions, DISC, BIN2DEC, IM* and the other Analysis ToolPak functions give `#VALUE!` for a reference to more than one cell in a value argument, as Excel 365 does, instead of spilling; array constants and computed arrays still lift |
|
||||
| 2026-10-14 | Protect Sheet on a protected sheet: Home › Format, its keytips (HOP, RPS) and the tab menu now read Unprotect Sheet… and unprotect (straight away without a password); a protected sheet that forbids selecting locked (or unlocked) cells is enforced for clicks, arrows and Go To, and the Protect Sheet dialog has the select options (#1346) |
|
||||
| 2026-10-14 | `.xls` (Excel 97-2003) files open with their formatting: fonts, solid and pattern fills, borders (with diagonals), alignment (rotation, indent, wrap, shrink), locked and hidden cells, the workbook's custom colour palette, its Normal font and its user-defined cell styles |
|
||||
| 2026-10-14 | XLSX: formulas opened with dynamic-array metadata (`cm`) keep it on save, with `xl/metadata.xml`. Before, a formula returning one value lost it (11 of 40 corpus files that have the part lost it entirely), and Excel recalculated some of them with implicit intersection (`SUMPRODUCT(IFERROR(...))` changed value) |
|
||||
| 2026-10-14 | Recalculation: a dynamic array's anchor given a new formula that reads its old spill area (`A1` was `=SEQUENCE(3)`, becomes `=SUM(A2:A3)+1` or `=SEQUENCE(1,A2+1)`) sees that area empty instead of the old values |
|
||||
| 2026-10-14 | Recalculation: when two arrays of one recalculation want the same empty cells, the formulas that read the one laid out second (at its anchor or over the cells it would have spilled into) read `#SPILL!` and empty cells instead of its values |
|
||||
@@ -179,6 +193,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-13 | XLSX: `[Content_Types].xml`, `_rels/.rels`, the workbook rels and `xl/workbook.xml` are the first zip entries (document properties last), so libmagic and PHP `finfo` detect the file type even with large properties (#1090) |
|
||||
| 2026-10-13 | Conditional formatting: a rule gathers range statistics only when it reads them (top/bottom, above average, duplicates, scales, bars, icons) and borrows them instead of copying them per cell, so the per-frame lookup of an expression or cell-value rule no longer visits its whole range (#1098, part 1) |
|
||||
| 2026-10-12 | Next/previous sheet navigation stops at the first/last visible worksheet instead of activating a hidden boundary sheet; the current selection is retained |
|
||||
| 2026-10-21 | Macro engine ([macros.md](docs/macros.md)): typed, declarative macros saved in the workbook (a JSON field; hidden names in xlsx); declared inputs checked with messages that name the cells; tokens (`Input.col(2)`, `Row`, `Output`, `Name:Total`, `{{…}}` in formulas); one undo step with full rollback; a footprint dry run with ask, fail, overwrite, shift down or shift right; a policy that keeps macros away from files and the app, with approval for macros in opened files; Repeat, If and Stop steps; a debugger; a relative recorder; `macro.*` commands beside the unchanged Automate scripts |
|
||||
| 2026-10-12 | Security: the `--control` server requires an auth token (random, kept in a 0600 file or chosen with `GRIDCRAFT_CONTROL_TOKEN`) on each connection's first line and closes bad connections (#777) |
|
||||
| 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) |
|
||||
| 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) |
|
||||
@@ -231,7 +246,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-11 | PivotCharts (`insert.pivotChart`): charts bound to a PivotTable's report, following refreshes and layout changes (#311) |
|
||||
| 2026-10-11 | FORECAST.ETS, FORECAST.ETS.CONFINT, FORECAST.ETS.SEASONALITY, FORECAST.ETS.STAT: AAA exponential smoothing forecasts (#314) |
|
||||
| 2026-10-11 | File tab Backstage view (Home, New, Open, Info, Save As, Print preview, Export, Options) and `file.options` (#174) |
|
||||
| 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured to the craftrules standard; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed |
|
||||
| 2026-10-10 | Ribbon translated into Japanese, Chinese, Korean, Brazilian Portuguese and Russian with system-language default (#31, #104, #82); CJK/Thai/Hebrew font fallback (#38); clear messages for encrypted and `.xls` files (#17); smooth line charts (#62). Progress docs restructured; full re-measure (this page). Initial ribbon key tips (#43), in-place text box editing (#84), copy as HTML table (#36). ODS and XLSB data import (#100, #101); recalc linear in the number of range formulas (#66); RISC-V Linux build; Flatpak repository on Pages; web cell-commit trap fixed |
|
||||
| 2026-10-09 | Community fixes: spill range operator `A1#`, sheet-scoped names, protection refuses locked-cell edits, Insert/Delete Cells adjust references, `DATE(1900,2,29)`, SUMIF sizing, `ROUND` at 15 digits, `_xlpm.` LAMBDA parameters in XLSX, vertical scrollbar, system appearance, border previews; v0.4.0 |
|
||||
| 2026-10-08 | v0.2.0 and v0.3.0 releases; Flatpak, AppImage zsync, branded DMG; desktop log file; DirectX 12 default on Windows |
|
||||
| 2026-10-07 | About window with contributor and model credits; macOS release runner |
|
||||
@@ -242,7 +257,20 @@ 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 |
|
||||
| 2026-10-31 | minor | Progress log: Function Library |
|
||||
| 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column |
|
||||
| 2026-10-24 | minor | Macro interface on the Automate tab; progress log row |
|
||||
| 2026-10-24 | minor | Macro engine and `macro.*` commands; catalog 275 / 291 → 276 / 292 (View › Macros › Record Macro); progress log row |
|
||||
| 2026-10-23 | minor | Progress log: accounting negatives in number text |
|
||||
| 2026-10-23 | minor | Progress log: INDEX with one index on a 2-D reference is #REF! |
|
||||
| 2026-10-22 | trivial | Progress log: formula hardening (#2163, #2162) |
|
||||
@@ -258,6 +286,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-16 | minor | Progress log: floating pictures' textures bounded and kept per workbook |
|
||||
| 2026-10-16 | trivial | Progress log: web preferences kept between visits (#1562) |
|
||||
| 2026-10-16 | minor | Progress log: interface fonts held once instead of twice |
|
||||
| 2026-10-14 | minor | Progress log: `.xls` import reads cell formatting |
|
||||
| 2026-10-14 | minor | Progress log: lookups compare numbers exactly |
|
||||
| 2026-10-14 | minor | Progress log: Analysis ToolPak value arguments reject multi-cell references |
|
||||
| 2026-10-14 | minor | XLSX dynamic-array `cm` metadata kept on save; progress log row |
|
||||
@@ -292,5 +321,5 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience; full number unchanged (additive weighted sum) |
|
||||
| 2026-10-10 | minor | Added Mainstream practitioner (~50%) and Essentials user (~63%); languages updated for the 10-10 merges (ribbon in ja, zh, ko, pt-BR, ru); localization 5%→10%; full number re-checked (51.6, unchanged) |
|
||||
| 2026-10-10 | minor | Checked against the alpha gate (six core workflows pass): stays alpha |
|
||||
| 2026-10-10 | major | Re-measured against Excel for Mac 16.113.4: stage alpha, breadth ~75%, ready ~50%, ~160–240 h to beta, ~700–1,100 h to full parity; restructured to the progress-docs standard; detail moved to `docs/` |
|
||||
| 2026-10-10 | major | Re-measured against Excel for Mac 16.113.4: stage alpha, breadth ~75%, ready ~50%, ~160–240 h to beta, ~700–1,100 h to full parity; progress docs restructured; detail moved to `docs/` |
|
||||
| 2026-10-07 | major | First estimates: catalog 89%, functions ~93%, depth ~65%, ~35 h to alpha, ~215 h to full parity |
|
||||
|
||||
@@ -230,8 +230,9 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
&& let gridcraft_engine::links::LinkKind::External(url) = gridcraft_engine::links::classify(url)
|
||||
{
|
||||
// With `noopener` the new browsing context can't hold a reference back to the
|
||||
// editor window and navigate it while the user reads the linked page.
|
||||
let _ = w.open_with_url_and_target(&url, "_blank,noopener");
|
||||
// editor window and navigate it while the user reads the linked page. It must be a
|
||||
// window *feature*: inside the target string it would only name the new window.
|
||||
let _ = w.open_with_url_and_target_and_features(&url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
})),
|
||||
inbox: Some(inbox),
|
||||
|
||||
@@ -24,13 +24,17 @@ 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).
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
craft-update = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
arboard = { workspace = true }
|
||||
@@ -52,6 +56,10 @@ muda = { version = "0.21", default-features = false }
|
||||
# by PdfCraft and PhotoCraft) registers the handler (Objective-C interop needs unsafe, which our
|
||||
# workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping.
|
||||
fmv-macos-events = "=0.1.0"
|
||||
# apple_events.rs brings the app forward for a Dock quit's question. Already in Cargo.lock (muda,
|
||||
# winit); pinned exactly to the same versions.
|
||||
objc2 = "=0.6.5"
|
||||
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow"] }
|
||||
|
||||
# tests/mac_menu_appkit.rs: reads AppKit's menu bar back. Already in Cargo.lock (muda, winit);
|
||||
# pinned exactly to the same versions.
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
|
||||
use fmv_macos_events::{Event, Inbox, Registration};
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2_app_kit::{NSApplication, NSRequestUserAttentionType};
|
||||
|
||||
/// Keeps the Apple-event handlers registered; hold it until the event loop returns.
|
||||
pub struct AppleEvents {
|
||||
@@ -44,7 +46,32 @@ pub fn poll(inbox: &Inbox, app: &mut SheetApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
// Quit from the Dock or the system: each unsaved workbook is asked about in its window.
|
||||
Event::Quit => app.request_quit(ctx),
|
||||
Event::Quit => {
|
||||
app.request_quit(ctx);
|
||||
if app.dialog.is_some() {
|
||||
bring_app_forward();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A Dock quit reaches GridCraft behind other apps, hidden or minimized, and its question has to
|
||||
/// be seen. egui's Focus goes through winit, which skips a minimized window or a hidden app, and
|
||||
/// macOS may refuse an activation it didn't expect, so ask AppKit directly and bounce the Dock
|
||||
/// icon until the user switches over.
|
||||
fn bring_app_forward() {
|
||||
let Some(mtm) = MainThreadMarker::new() else { return };
|
||||
let ns_app = NSApplication::sharedApplication(mtm);
|
||||
ns_app.unhide(None);
|
||||
for window in ns_app.windows().to_vec() {
|
||||
if window.isMiniaturized() {
|
||||
window.deminiaturize(None);
|
||||
}
|
||||
}
|
||||
#[allow(deprecated)] // `activate` needs macOS 14; this works on every supported version.
|
||||
ns_app.activateIgnoringOtherApps(true);
|
||||
if !ns_app.isActive() {
|
||||
ns_app.requestUserAttention(NSRequestUserAttentionType::CriticalRequest);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,16 +12,47 @@
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use gridcraft_mcp::control_auth;
|
||||
use gridcraft_ui_egui::ControlRequest;
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
const MAX_LINE: usize = 64 * 1024 * 1024;
|
||||
/// How long a new connection may take to send its token.
|
||||
/// How long a new connection may take to send its token, in total.
|
||||
const AUTH_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// How many connections may be waiting to authenticate at once. Anyone on the machine can
|
||||
/// connect without the token, so past this a new connection is closed before anything is read:
|
||||
/// each waiting one holds a thread and a socket.
|
||||
const MAX_PENDING_AUTH: usize = 16;
|
||||
|
||||
/// How the server treats connections that haven't authenticated yet.
|
||||
#[derive(Clone, Copy)]
|
||||
struct AuthLimits {
|
||||
timeout: Duration,
|
||||
max_pending: usize,
|
||||
}
|
||||
|
||||
const AUTH_LIMITS: AuthLimits = AuthLimits { timeout: AUTH_TIMEOUT, max_pending: MAX_PENDING_AUTH };
|
||||
|
||||
/// A connection that is still waiting to authenticate; dropping it frees its place.
|
||||
struct Pending(Arc<AtomicUsize>);
|
||||
|
||||
impl Pending {
|
||||
/// A place among the `max` waiting connections, or `None` when they are all taken.
|
||||
fn claim(waiting: &Arc<AtomicUsize>, max: usize) -> Option<Pending> {
|
||||
waiting.try_update(Ordering::AcqRel, Ordering::Acquire, |n| (n < max).then_some(n + 1)).ok()?;
|
||||
Some(Pending(Arc::clone(waiting)))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Pending {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_sub(1, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
|
||||
/// The secret of this run: the one the user chose (`--control-token`, `GRIDCRAFT_CONTROL_TOKEN`),
|
||||
/// else 256 random bits from the operating system, written to a file only the user can read.
|
||||
@@ -51,35 +82,75 @@ pub fn start(port: u16, chosen_token: Option<String>, ctx: egui::Context) -> std
|
||||
|
||||
/// Accept connections on `listener`; requests arrive on the returned receiver.
|
||||
fn serve_listener(listener: TcpListener, token: Arc<str>, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
serve_listener_with(listener, token, ctx, AUTH_LIMITS)
|
||||
}
|
||||
|
||||
fn serve_listener_with(listener: TcpListener, token: Arc<str>, ctx: egui::Context, limits: AuthLimits) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
let (tx, rx) = channel();
|
||||
let waiting = Arc::new(AtomicUsize::new(0));
|
||||
std::thread::Builder::new().name("control".into()).spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
for stream in listener.incoming() {
|
||||
let Ok(stream) = stream else {
|
||||
// Out of sockets or handles: wait for some to free instead of spinning on the error.
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
continue;
|
||||
};
|
||||
// Dropping the stream closes it before anything is read.
|
||||
let Some(place) = Pending::claim(&waiting, limits.max_pending) else { continue };
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
let token = Arc::clone(&token);
|
||||
let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, &token, tx, ctx));
|
||||
let _ =
|
||||
std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, &token, tx, ctx, limits.timeout, Some(place)));
|
||||
}
|
||||
})?;
|
||||
Ok(rx)
|
||||
}
|
||||
|
||||
/// Read the connection's first line (at most [`control_auth::MAX_AUTH_LINE`] bytes, within
|
||||
/// [`AUTH_TIMEOUT`]) and check the token. `true` when the peer may go on.
|
||||
fn authenticate(reader: &mut BufReader<TcpStream>, out: &mut TcpStream, token: &str) -> bool {
|
||||
let _ = out.set_read_timeout(Some(AUTH_TIMEOUT));
|
||||
let mut first = String::new();
|
||||
let read = reader.by_ref().take(control_auth::MAX_AUTH_LINE as u64).read_line(&mut first);
|
||||
let verdict = match read {
|
||||
Ok(n) if n > 0 && first.ends_with('\n') => control_auth::check_first_line(&first, token),
|
||||
/// `timeout` in total) and check the token. `true` when the peer may go on.
|
||||
#[allow(clippy::disallowed_methods)] // the desktop app (native only): wasm never runs it
|
||||
fn authenticate(reader: &mut BufReader<TcpStream>, out: &mut TcpStream, token: &str, timeout: Duration) -> bool {
|
||||
let deadline = Instant::now().checked_add(timeout).unwrap_or_else(Instant::now);
|
||||
let mut first = Vec::new();
|
||||
let read = read_auth_line(reader, &mut first, deadline);
|
||||
let verdict = match (read, std::str::from_utf8(&first)) {
|
||||
(Ok(()), Ok(line)) if line.ends_with('\n') => control_auth::check_first_line(line, token),
|
||||
_ => Err(json!({"ok": false, "error": "authentication required"})),
|
||||
};
|
||||
let allowed = verdict.is_ok();
|
||||
let reply = verdict.unwrap_or_else(|e| e);
|
||||
let _ = writeln!(out, "{reply}");
|
||||
let _ = out.set_read_timeout(None);
|
||||
let _ = reader.get_ref().set_read_timeout(None);
|
||||
allowed
|
||||
}
|
||||
|
||||
/// Read the first line, at most [`control_auth::MAX_AUTH_LINE`] bytes, by `deadline`.
|
||||
///
|
||||
/// The timeout goes on the handle the reader reads: on Windows a `try_clone` of the stream keeps
|
||||
/// its own, so one set on the writing half never fired (a silent peer held its thread forever).
|
||||
/// It is shortened before every read, so a peer sending one byte at a time can't stretch it.
|
||||
#[allow(clippy::disallowed_methods)] // the desktop app (native only): wasm never runs it
|
||||
fn read_auth_line(reader: &mut BufReader<TcpStream>, line: &mut Vec<u8>, deadline: Instant) -> std::io::Result<()> {
|
||||
let max = control_auth::MAX_AUTH_LINE;
|
||||
while line.len() < max && line.last() != Some(&b'\n') {
|
||||
let left = deadline.saturating_duration_since(Instant::now());
|
||||
if left.is_zero() {
|
||||
return Err(std::io::ErrorKind::TimedOut.into());
|
||||
}
|
||||
reader.get_ref().set_read_timeout(Some(left))?;
|
||||
let buf = reader.fill_buf()?;
|
||||
if buf.is_empty() {
|
||||
break;
|
||||
}
|
||||
let room = max.saturating_sub(line.len());
|
||||
let take = buf.iter().take(room).position(|b| *b == b'\n').map_or(buf.len().min(room), |i| i + 1);
|
||||
line.extend(buf.iter().take(take));
|
||||
reader.consume(take);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
|
||||
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
|
||||
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
|
||||
@@ -148,11 +219,14 @@ fn is_http_request_line(text: &str) -> bool {
|
||||
|| text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/"))
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
fn serve(stream: TcpStream, token: &str, tx: Sender<ControlRequest>, ctx: egui::Context, auth_timeout: Duration, place: Option<Pending>) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
let mut reader = BufReader::new(read);
|
||||
if !authenticate(&mut reader, &mut out, token) {
|
||||
let allowed = authenticate(&mut reader, &mut out, token, auth_timeout);
|
||||
// Authenticated or refused, the connection no longer counts as waiting.
|
||||
drop(place);
|
||||
if !allowed {
|
||||
return;
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
@@ -294,7 +368,7 @@ mod tests {
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
std::thread::spawn(move || serve(stream, TOKEN, tx, egui::Context::default()));
|
||||
std::thread::spawn(move || serve(stream, TOKEN, tx, egui::Context::default(), AUTH_TIMEOUT, None));
|
||||
}
|
||||
});
|
||||
addr
|
||||
@@ -346,6 +420,81 @@ mod tests {
|
||||
assert_eq!(got[1]["ok"], json!(false));
|
||||
}
|
||||
|
||||
/// Whether the server closed `c` (end of stream, or a refusal and then end of stream) within
|
||||
/// `within`, and how long it took.
|
||||
#[allow(clippy::disallowed_methods)] // a native-only test of the desktop app
|
||||
fn closed_within(c: TcpStream, within: Duration) -> (bool, Duration) {
|
||||
let start = Instant::now();
|
||||
c.set_read_timeout(Some(within)).unwrap();
|
||||
let mut r = BufReader::new(c);
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
match r.read_line(&mut line) {
|
||||
Ok(0) => return (true, start.elapsed()),
|
||||
Ok(_) => continue,
|
||||
Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => return (false, start.elapsed()),
|
||||
Err(_) => return (true, start.elapsed()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The token deadline is for the whole first line: neither a silent peer nor one sending a
|
||||
/// byte at a time keeps the connection (and its thread) past it.
|
||||
#[test]
|
||||
fn a_silent_or_slow_peer_is_closed_at_the_auth_deadline() {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let limits = AuthLimits { timeout: Duration::from_millis(300), max_pending: MAX_PENDING_AUTH };
|
||||
let _rx = serve_listener_with(listener, TOKEN.into(), egui::Context::default(), limits).unwrap();
|
||||
|
||||
let silent = TcpStream::connect(addr).unwrap();
|
||||
let mut slow = TcpStream::connect(addr).unwrap();
|
||||
let mut drip = slow.try_clone().unwrap();
|
||||
std::thread::spawn(move || {
|
||||
for b in control_auth::auth_line(1, TOKEN).bytes() {
|
||||
if drip.write_all(&[b]).is_err() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
});
|
||||
let (closed, took) = closed_within(silent, Duration::from_secs(5));
|
||||
assert!(closed && took < Duration::from_secs(3), "silent peer kept for {took:?}");
|
||||
let (closed, took) = closed_within(slow.try_clone().unwrap(), Duration::from_secs(5));
|
||||
assert!(closed && took < Duration::from_secs(3), "byte-at-a-time peer kept for {took:?}");
|
||||
let _ = slow.flush();
|
||||
}
|
||||
|
||||
/// Peers without the token can't pile up threads: past the cap a new connection is closed at
|
||||
/// once, and a waiting one that authenticates frees its place.
|
||||
#[test]
|
||||
fn connections_waiting_to_authenticate_are_capped() {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let limits = AuthLimits { timeout: Duration::from_secs(20), max_pending: 2 };
|
||||
let _rx = serve_listener_with(listener, TOKEN.into(), egui::Context::default(), limits).unwrap();
|
||||
|
||||
let mut first = TcpStream::connect(addr).unwrap();
|
||||
let _second = TcpStream::connect(addr).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
let (closed, took) = closed_within(TcpStream::connect(addr).unwrap(), Duration::from_secs(5));
|
||||
assert!(closed && took < Duration::from_secs(3), "a third waiting connection was kept");
|
||||
|
||||
first.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
first.write_all(format!("{}\n", control_auth::auth_line(1, TOKEN)).as_bytes()).unwrap();
|
||||
let mut reply = String::new();
|
||||
BufReader::new(first.try_clone().unwrap()).read_line(&mut reply).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Json>(&reply).unwrap()["ok"], json!(true), "{reply}");
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
let mut next = TcpStream::connect(addr).unwrap();
|
||||
next.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
next.write_all(format!("{}\n", control_auth::auth_line(1, TOKEN)).as_bytes()).unwrap();
|
||||
let mut reply = String::new();
|
||||
BufReader::new(next).read_line(&mut reply).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Json>(&reply).unwrap()["ok"], json!(true), "a freed place was not reused: {reply}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chosen_token_must_be_long_enough() {
|
||||
assert!(make_token(1, Some("short".into())).is_err());
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
|
||||
//! Graphics instance configuration, applied before wgpu can initialize any driver: Windows
|
||||
//! backends and the DirectX 12 shader compiler, and the Intel Mac indirect-validation skip.
|
||||
//!
|
||||
//! Vulkan is never initialized unless `WGPU_BACKEND` asks for it: enumerating Vulkan adapters
|
||||
//! crashes inside Intel's `igvk64.dll` on some UHD Graphics drivers (#120), and that access
|
||||
@@ -8,12 +9,38 @@
|
||||
//! whichever `dxcompiler.dll` the DLL search path happens to find (#339, #354): see
|
||||
//! [`dx12_compiler`].
|
||||
|
||||
// On macOS only the indirect-validation items are used outside tests; on Windows the other way
|
||||
// round, so those two carry their own `allow`.
|
||||
#![cfg_attr(target_os = "macos", allow(dead_code))]
|
||||
|
||||
use eframe::{
|
||||
NativeOptions,
|
||||
egui_wgpu::WgpuSetup,
|
||||
wgpu::{Backends, DeviceType, Dx12Compiler},
|
||||
wgpu::{Backends, DeviceType, Dx12Compiler, InstanceFlags},
|
||||
};
|
||||
|
||||
/// wgpu compiles its indirect-call validation compute shaders inside `request_device`. On Intel
|
||||
/// Macs (Iris 5100 in a Mac mini 2014) Metal's compiler aborts on them ("Compiler encountered an
|
||||
/// internal error"), the device is lost and eframe exits before the window opens. GridCraft issues
|
||||
/// no indirect draws or dispatches, so Intel Macs skip them; every other GPU keeps wgpu's default.
|
||||
/// Same as WordCraft (storytold/wordcraft#2652) and PhotoCraft (storytold/PhotoCraft#3171).
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
pub fn skips_indirect_validation(macos: bool, intel: bool) -> bool {
|
||||
macos && intel
|
||||
}
|
||||
|
||||
/// Leaves wgpu's indirect-call validation out of the instance eframe creates when `skip`.
|
||||
/// `WGPU_VALIDATION_INDIRECT_CALL=1` still turns it back on.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
pub fn configure_indirect_validation(options: &mut NativeOptions, skip: bool) {
|
||||
if !skip {
|
||||
return;
|
||||
}
|
||||
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
create.instance_descriptor.flags = create.instance_descriptor.flags.difference(InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
|
||||
}
|
||||
}
|
||||
|
||||
/// What the start-up probe learned about one DirectX 12 adapter.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Dx12Adapter {
|
||||
@@ -141,6 +168,55 @@ fn probe_dx12(options: &NativeOptions) -> Vec<Dx12Adapter> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn instance_flags(options: &NativeOptions) -> InstanceFlags {
|
||||
let WgpuSetup::CreateNew(create) = &options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
create.instance_descriptor.flags
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intel_macs_skip_the_indirect_validation_shaders() {
|
||||
// Metal's Intel compiler aborted on them, losing the device before the window opened.
|
||||
assert!(skips_indirect_validation(true, true));
|
||||
// Apple silicon, Windows and Linux keep wgpu's validation.
|
||||
for (macos, intel) in [(true, false), (false, true), (false, false)] {
|
||||
assert!(!skips_indirect_validation(macos, intel), "macos={macos} intel={intel}");
|
||||
}
|
||||
let mut options = NativeOptions::default();
|
||||
let before = instance_flags(&options);
|
||||
configure_indirect_validation(&mut options, false);
|
||||
assert_eq!(instance_flags(&options), before, "flags untouched when not skipping");
|
||||
if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_none() {
|
||||
configure_indirect_validation(&mut options, true);
|
||||
assert!(!instance_flags(&options).contains(InstanceFlags::VALIDATION_INDIRECT_CALL));
|
||||
}
|
||||
}
|
||||
|
||||
/// `WGPU_VALIDATION_INDIRECT_CALL=1` turns the validation back on even when it is skipped.
|
||||
/// Tests can't set environment variables (`set_var` is unsafe, and unsafe code is forbidden),
|
||||
/// so this runs itself again as a child process with the variable set.
|
||||
#[test]
|
||||
fn the_environment_turns_indirect_validation_back_on() {
|
||||
const NAME: &str = "graphics::tests::the_environment_turns_indirect_validation_back_on";
|
||||
if std::env::var_os("GRIDCRAFT_INDIRECT_VALIDATION_CHILD").is_some() {
|
||||
let mut options = NativeOptions::default();
|
||||
configure_indirect_validation(&mut options, true);
|
||||
assert!(instance_flags(&options).contains(InstanceFlags::VALIDATION_INDIRECT_CALL));
|
||||
return;
|
||||
}
|
||||
let exe = std::env::current_exe().unwrap();
|
||||
let out = std::process::Command::new(exe)
|
||||
.args(["--exact", NAME, "--test-threads=1"])
|
||||
.env("GRIDCRAFT_INDIRECT_VALIDATION_CHILD", "1")
|
||||
.env("WGPU_VALIDATION_INDIRECT_CALL", "1")
|
||||
.output()
|
||||
.unwrap();
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
// `--exact` matching nothing would pass too, so the child must report this one test.
|
||||
assert!(out.status.success() && stdout.contains("1 passed"), "child test failed:\n{stdout}{}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
|
||||
fn configured_backends(backends: Backends) -> Backends {
|
||||
let mut options = NativeOptions::default();
|
||||
configure_backends(&mut options, backends);
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
mod apple_events;
|
||||
mod control_server;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
#[cfg(any(target_os = "windows", target_os = "macos", test))]
|
||||
mod graphics;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -256,7 +256,7 @@ fn main() -> eframe::Result<()> {
|
||||
}));
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
// The restart helper a self-update spawns: it finishes the swap and relaunches, then exits.
|
||||
if let Some(done) = craft_update::self_update::finish(&craft_update::suite::GRIDCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
if let Some(done) = craft_update::self_update::finish(&gridcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
if let Err(e) = done {
|
||||
eprintln!("GridCraft update helper failed: {e}");
|
||||
std::process::exit(1);
|
||||
@@ -311,15 +311,16 @@ fn main() -> eframe::Result<()> {
|
||||
{
|
||||
log::warn!("sample {s}: {e}");
|
||||
}
|
||||
if session.documents().is_empty() {
|
||||
session.new_workbook();
|
||||
}
|
||||
let mut app = SheetApp::new(session, services());
|
||||
app.watch_desktop_theme();
|
||||
app.start_update_check();
|
||||
app.after_engine(); // Show warnings from the sample.
|
||||
let control_port = control_port;
|
||||
load_prefs(&mut app);
|
||||
// Stamp the startup workbook's author from the persisted user name.
|
||||
if app.session.documents().is_empty() {
|
||||
app.session.new_workbook();
|
||||
}
|
||||
app.after_engine(); // Show warnings from the sample.
|
||||
// Files named on the command line (a double-click on Windows and Linux) open as File › Open
|
||||
// opens them: a password-protected one asks for its password, and errors are shown, not
|
||||
// only logged. Each replaces the untouched blank Book1, as a file opened from Finder does.
|
||||
@@ -340,8 +341,11 @@ fn main() -> eframe::Result<()> {
|
||||
}
|
||||
let window_builder = viewport.clone();
|
||||
let options = eframe::NativeOptions { viewport, ..Default::default() };
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[cfg(unix)]
|
||||
let mut options = options;
|
||||
// Intel Macs: Metal's compiler aborts on wgpu's indirect-validation shaders (see graphics.rs).
|
||||
#[cfg(target_os = "macos")]
|
||||
graphics::configure_indirect_validation(&mut options, graphics::skips_indirect_validation(true, cfg!(target_arch = "x86_64")));
|
||||
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
|
||||
// manager showed a "no" cursor. Run through XWayland when it's there; GRIDCRAFT_WAYLAND=1
|
||||
// keeps the native Wayland backend.
|
||||
|
||||
@@ -390,11 +390,12 @@ impl NativeMenu {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
// No `use super::*`: tests/mac_menu_appkit.rs builds this file with cfg(test) but no test
|
||||
// harness, so the #[test] functions are left out and a module-level import would be unused.
|
||||
|
||||
#[test]
|
||||
fn file_menu_has_export_as_pdf_dispatching_the_command() {
|
||||
let tree = tree();
|
||||
let tree = super::tree();
|
||||
let (_, file) = tree.iter().find(|(title, _)| *title == "File").expect("File menu");
|
||||
let item = file.iter().find(|e| e.0 == "Export as PDF…").expect("Export as PDF… in the File menu");
|
||||
assert_eq!(item.1, "file.exportPdf");
|
||||
@@ -403,23 +404,23 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn help_menu_checks_for_updates_through_the_registered_command() {
|
||||
let tree = tree();
|
||||
let tree = super::tree();
|
||||
let (_, help) = tree.iter().find(|(title, _)| *title == "Help").expect("Help menu");
|
||||
let item = help.iter().find(|e| e.0 == "Check for Updates…").expect("Check for Updates… in the Help menu");
|
||||
assert_eq!(item.1, "app.checkForUpdates");
|
||||
let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default());
|
||||
let mut app = super::SheetApp::new(gridcraft_engine::Session::new(), Default::default());
|
||||
assert!(app.run(item.1, serde_json::json!({})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_choices_use_the_shared_preference_command() {
|
||||
let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default());
|
||||
let mut app = super::SheetApp::new(gridcraft_engine::Session::new(), Default::default());
|
||||
for mode in ["dark", "system", "light"] {
|
||||
assert!(select_theme(&mut app, &format!("theme:{mode}")));
|
||||
assert!(super::select_theme(&mut app, &format!("theme:{mode}")));
|
||||
assert_eq!(app.ui.theme_mode(), mode);
|
||||
assert!(app.message.is_none());
|
||||
}
|
||||
assert!(!select_theme(&mut app, "view.formulaBar"));
|
||||
assert!(!super::select_theme(&mut app, "view.formulaBar"));
|
||||
assert_eq!(app.ui.theme_mode(), "light");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,12 +19,23 @@ fn main() {
|
||||
nsapp.finishLaunching();
|
||||
|
||||
let ctx = egui::Context::default();
|
||||
let _menu = native_menu::NativeMenu::install(&ctx);
|
||||
let mut menu = native_menu::NativeMenu::install(&ctx);
|
||||
|
||||
let bar = nsapp.mainMenu().expect("a main menu");
|
||||
let tops: Vec<String> = (0..bar.numberOfItems()).filter_map(|i| bar.itemAtIndex(i)?.submenu()).map(|m| m.title().to_string()).collect();
|
||||
assert_eq!(tops, ["GridCraft", "File", "Edit", "View", "Insert", "Format", "Tools", "Data", "Window", "Help"]);
|
||||
assert_eq!(nsapp.windowsMenu().map(|m| m.title().to_string()).as_deref(), Some("Window"), "AppKit knows the Window menu");
|
||||
let windows_menu = nsapp.windowsMenu().expect("AppKit knows the Window menu");
|
||||
assert_eq!(windows_menu.title().to_string(), "Window");
|
||||
|
||||
// A poll lists the open workbook windows at the end of the Window menu (#716).
|
||||
let mut session = gridcraft_engine::Session::new();
|
||||
session.new_workbook();
|
||||
let mut app = gridcraft_ui_egui::SheetApp::new(session, Default::default());
|
||||
menu.poll(&mut app, &ctx, egui::ViewportId::ROOT);
|
||||
let open = app.open_windows();
|
||||
assert_eq!(open.len(), 1, "one workbook window");
|
||||
let last = windows_menu.itemAtIndex(windows_menu.numberOfItems() - 1).expect("a Window menu item");
|
||||
assert_eq!(last.title().to_string(), open[0].label(), "the Window menu lists the open workbook");
|
||||
|
||||
println!("mac_menu_appkit: ok");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# GridCraft app icon (PLACEHOLDER)
|
||||
|
||||
**This icon is temporary.** Every Crafting App's icon is an engraved creature portrait that the
|
||||
product owner makes (craftrules `standards/icon-design.md`); GridCraft's hasn't been made yet.
|
||||
product owner makes; GridCraft's hasn't been made yet.
|
||||
Until it is, this placeholder stands in: a simple ledger page with a header row, a row-number
|
||||
column, a selected cell with its fill handle and a double rule under a total, on a full-bleed
|
||||
GridCraft-green tile. It is original geometry written by hand for this repository, deliberately
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<!-- GridCraft PLACEHOLDER icon: a ledger page with a selected cell, on GridCraft green.
|
||||
Original work; temporary until the owner's engraved creature portrait replaces it
|
||||
(craftrules standards/icon-design.md). Palette: ink #0b0b0c, paper #efe9dc,
|
||||
Palette: ink #0b0b0c, paper #efe9dc,
|
||||
app colour #1f9d55 (proposed). -->
|
||||
<defs>
|
||||
<clipPath id="tile"><rect width="512" height="512" rx="112"/></clipPath>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -1,7 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<!-- GridCraft PLACEHOLDER icon: a ledger page with a selected cell, on GridCraft green.
|
||||
Original work; temporary until the owner's engraved creature portrait replaces it
|
||||
(craftrules standards/icon-design.md). Palette: ink #0b0b0c, paper #efe9dc,
|
||||
Palette: ink #0b0b0c, paper #efe9dc,
|
||||
app colour #1f9d55 (proposed). -->
|
||||
<defs>
|
||||
<clipPath id="tile"><rect width="512" height="512" rx="112"/></clipPath>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -1,13 +1,10 @@
|
||||
# Contributor names for the About window (craftrules standards/contributors.md).
|
||||
# Contributor names for the About window.
|
||||
#
|
||||
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
|
||||
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
|
||||
# maintainer may add a person only when that person asked, recording where in consent_source.
|
||||
#
|
||||
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
|
||||
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
|
||||
#
|
||||
# Or copy this block, replacing your-github-username (every field is optional):
|
||||
# Copy this block, replacing your-github-username (every field is optional):
|
||||
#
|
||||
# [people.your-github-username]
|
||||
# real_name = "Your Name" # shown when the viewer picks "Real name"
|
||||
@@ -103,3 +100,12 @@ real_name = "Helen Kwok"
|
||||
[people.dotCooCoo]
|
||||
real_name = "Robert Lee"
|
||||
sync_github_name = true
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Plutonium-fream]
|
||||
sync_github_name = true
|
||||
|
||||
[people.maxm11]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -89,8 +89,14 @@ pub fn zip2(a: &Value, b: &Value, f: impl Fn(&Value, &Value) -> Value) -> Value
|
||||
/// Cell count of a `rows`×`cols` broadcast result, or `None` when it would exceed [`MAX_CELLS`].
|
||||
/// Each operand passes its own size cap, but their broadcast product can be far larger
|
||||
/// (`SEQUENCE(1048576)*SEQUENCE(1,16384)` is 17 billion cells), so it is checked before allocating.
|
||||
/// The product is formed in `u64`: on wasm32 `usize` is 32 bits, where a `usize` product above
|
||||
/// `u32::MAX` would wrap (2^32 cells → 0) and slip past the cap.
|
||||
pub(super) fn broadcast_len(rows: usize, cols: usize) -> Option<usize> {
|
||||
rows.checked_mul(cols).filter(|&n| n as u64 <= MAX_CELLS)
|
||||
let cells = u64::try_from(rows).ok()?.checked_mul(u64::try_from(cols).ok()?)?;
|
||||
if cells > MAX_CELLS {
|
||||
return None;
|
||||
}
|
||||
usize::try_from(cells).ok()
|
||||
}
|
||||
|
||||
fn broadcast_dim(a: usize, b: usize) -> usize {
|
||||
@@ -124,7 +130,7 @@ pub(super) fn zip3(c: &Array, t: &Value, f: &Value, g: impl Fn(&Value, &Value, &
|
||||
/// can't nest, so the whole helper gives `#CALC!`, as in Excel.
|
||||
pub(super) fn single(v: Value) -> Option<Value> {
|
||||
match v {
|
||||
Value::Array(a) if a.rows * a.cols > 1 => None,
|
||||
Value::Array(a) if a.data.len() > 1 => None,
|
||||
v => Some(v.scalar()),
|
||||
}
|
||||
}
|
||||
@@ -178,3 +184,24 @@ fn binary_scalar(op: BinOp, a: &Value, b: &Value, sys: gridcraft_core::DateSyste
|
||||
BinOp::Range | BinOp::Union | BinOp::Intersect => Value::Error(CellError::Value),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{MAX_CELLS, broadcast_len};
|
||||
|
||||
#[test]
|
||||
fn broadcast_len_caps_at_max_cells() {
|
||||
assert_eq!(broadcast_len(4_000, 4_000), Some(MAX_CELLS as usize));
|
||||
assert_eq!(broadcast_len(4_000, 4_001), None);
|
||||
assert_eq!(broadcast_len(1, MAX_CELLS as usize), Some(MAX_CELLS as usize));
|
||||
}
|
||||
|
||||
/// `65_536 * 65_536` is 2^32. Multiplied as `usize` on wasm32 it wraps to 0, which a
|
||||
/// `> MAX_CELLS` test would let through; the guard must reject the true product.
|
||||
#[test]
|
||||
fn broadcast_len_rejects_a_product_that_wraps_to_zero_on_32_bits() {
|
||||
assert_eq!(65_536u32.wrapping_mul(65_536), 0);
|
||||
assert_eq!(broadcast_len(65_536, 65_536), None);
|
||||
assert_eq!(broadcast_len(1_048_576, 16_384), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -614,6 +614,25 @@ fn lambda_calls_spend_one_work_budget_per_cell() {
|
||||
assert_eq!(t.num("A2"), 4000.0 * 4001.0 / 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifted_lookups_stop_past_their_work_budget() {
|
||||
// Security: each needle searched the whole lookup vector, so this one cell ran for hours
|
||||
// (about 1.1e12 compares) and hung opening the file that held it. It is #NUM! at once now.
|
||||
let mut t = T::new();
|
||||
t.set("A1", "=ROWS(XMATCH(SEQUENCE(1048576),-SEQUENCE(1048576)))");
|
||||
assert_eq!(t.get("A1"), Value::Error(CellError::Num));
|
||||
// Ordinary lifted lookups are unaffected.
|
||||
for (i, k) in [10, 20, 30, 40, 50].into_iter().enumerate() {
|
||||
t.set(&format!("B{}", i + 1), &k.to_string());
|
||||
t.set(&format!("C{}", i + 1), &(i + 1).to_string());
|
||||
}
|
||||
t.set("D1", "=SUM(XMATCH({30;50;10},B1:B5))");
|
||||
t.set("D2", "=SUM(XLOOKUP({20;40},B1:B5,C1:C5))");
|
||||
t.set("D3", "=SUM(VLOOKUP({30,10},B1:C5,2,FALSE))");
|
||||
t.set("D4", "=SUM(MATCH({25,50},B1:B5))");
|
||||
assert_eq!([t.num("D1"), t.num("D2"), t.num("D3"), t.num("D4")], [9.0, 6.0, 4.0, 7.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accounting_negative_text_coerces() {
|
||||
let mut t = T::new();
|
||||
@@ -1232,6 +1251,25 @@ fn array_broadcast_result_is_capped() {
|
||||
assert_eq!(t.num("C3"), 5.0);
|
||||
}
|
||||
|
||||
/// MAP refuses a result past `MAX_CELLS` by its checked product, before allocating it (the
|
||||
/// 2^32-cell wrap that a 32-bit `usize` product would turn into 0 is pinned in
|
||||
/// `broadcast_len_rejects_a_product_that_wraps_to_zero_on_32_bits`; `SEQUENCE` tops out at
|
||||
/// 16384 columns, so the widest product it can build is 16384 squared).
|
||||
#[test]
|
||||
fn map_caps_its_result_size() {
|
||||
let mut t = T::new();
|
||||
for (at, formula) in
|
||||
[("A1", "=MAP(SEQUENCE(4001),SEQUENCE(1,4000),LAMBDA(a,b,a))"), ("A2", "=MAP(SEQUENCE(16384),SEQUENCE(1,16384),LAMBDA(a,b,a))")]
|
||||
{
|
||||
t.set(at, formula);
|
||||
assert_eq!(t.get(at), Value::Error(CellError::Num), "{formula}");
|
||||
}
|
||||
// Within the cap the broadcast shape is unchanged.
|
||||
t.set("A3", "=ROWS(MAP(SEQUENCE(3),SEQUENCE(1,2),LAMBDA(a,b,a+b)))");
|
||||
t.set("A4", "=COLUMNS(MAP(SEQUENCE(3),SEQUENCE(1,2),LAMBDA(a,b,a+b)))");
|
||||
assert_eq!((t.num("A3"), t.num("A4")), (3.0, 2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_function() {
|
||||
let mut t = T::new();
|
||||
@@ -2274,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]
|
||||
|
||||
@@ -269,6 +269,7 @@ pub const CATALOG: &[(&str, &str, &str, &str)] = &[
|
||||
("view.unhideWindow", "View", "Window", "Unhide"),
|
||||
("view.switchWindows", "View", "Window", "Switch Windows"),
|
||||
("view.macros", "View", "Macros", "Macros"),
|
||||
("macro.record", "View", "Macros", "Record Macro"),
|
||||
// Automate
|
||||
("automate.newScript", "Automate", "Scripting Tools", "New Script"),
|
||||
("automate.allScripts", "Automate", "Scripting Tools", "All Scripts"),
|
||||
|
||||
@@ -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;
|
||||
@@ -12,8 +13,10 @@ pub mod format;
|
||||
pub mod formulas;
|
||||
pub mod insert;
|
||||
pub mod inspect;
|
||||
pub mod macros;
|
||||
pub mod pivot;
|
||||
pub mod print;
|
||||
pub mod python;
|
||||
pub mod review;
|
||||
pub mod sheet;
|
||||
pub mod slicer;
|
||||
@@ -39,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,
|
||||
@@ -53,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);
|
||||
@@ -67,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(),
|
||||
@@ -117,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;
|
||||
@@ -137,16 +165,19 @@ 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());
|
||||
v.extend(view::specs());
|
||||
v.extend(inspect::specs());
|
||||
v.extend(extra::specs());
|
||||
v.extend(macros::specs());
|
||||
v.extend(draw::specs());
|
||||
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());
|
||||
@@ -178,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,
|
||||
@@ -195,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,
|
||||
@@ -337,6 +370,9 @@ pub struct Ctx<'a> {
|
||||
/// The command applied sheet protection itself (row/column inserts and deletes the
|
||||
/// protection allows), so cells it shifts aren't refused as edits of locked cells.
|
||||
pub protection_checked: bool,
|
||||
/// The structural edits the command made, in order (the sheet's name, the edit), as
|
||||
/// references were rewritten for them.
|
||||
pub edits: Vec<(String, gridcraft_formula::adjust::Edit)>,
|
||||
}
|
||||
|
||||
pub(crate) fn edit<R>(s: &mut Session, f: impl FnOnce(&mut Ctx) -> Result<R>) -> Result<R> {
|
||||
@@ -358,15 +394,36 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
|
||||
let t0 = if cfg!(target_arch = "wasm32") { None } else { std::env::var_os("GRIDCRAFT_PROFILE").map(|_| wall_clock()) };
|
||||
let since = |t0: std::time::Duration| wall_clock().saturating_sub(t0);
|
||||
let mut sel = d.selection.clone();
|
||||
let mut ctx = Ctx { wb: (*d.wb).clone(), changed: Vec::new(), structural: false, sel: &mut sel, fit_rows: Vec::new(), protection_checked: false };
|
||||
let mut ctx = Ctx {
|
||||
wb: (*d.wb).clone(),
|
||||
changed: Vec::new(),
|
||||
structural: false,
|
||||
sel: &mut sel,
|
||||
fit_rows: Vec::new(),
|
||||
protection_checked: false,
|
||||
edits: Vec::new(),
|
||||
};
|
||||
let r = f(&mut ctx)?;
|
||||
let Ctx { mut wb, changed, structural, mut fit_rows, protection_checked, .. } = 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)?;
|
||||
}
|
||||
if !structural {
|
||||
check_arrays(&d.wb, &wb)?;
|
||||
}
|
||||
if let Some(log) = d.touched.as_mut() {
|
||||
for (si, c) in changed.iter().take(TOUCH_LOG_LIMIT.saturating_sub(log.len())) {
|
||||
if let Some(sh) = wb.sheet(*si) {
|
||||
log.push((sh.name.clone(), *c));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(log) = d.edits.as_mut() {
|
||||
log.extend(edits);
|
||||
}
|
||||
if changed.len() <= 200_000 {
|
||||
fit_rows.extend(changed.iter().map(|(s, c)| (*s, c.row)));
|
||||
}
|
||||
@@ -394,6 +451,10 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// The most cells a dry run's touch log keeps; a footprint whose log is full may be missing writes
|
||||
/// that kept a cell's value, so a shift is refused then.
|
||||
pub(crate) const TOUCH_LOG_LIMIT: usize = 1_000_000;
|
||||
|
||||
/// Excel's message for an edit of a locked cell on a protected sheet.
|
||||
pub(crate) const PROTECTED: &str = "The cell or chart you're trying to change is on a protected sheet.";
|
||||
|
||||
@@ -463,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;
|
||||
}
|
||||
@@ -479,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 {
|
||||
|
||||
@@ -20,7 +20,7 @@ mod paste_special;
|
||||
mod selection;
|
||||
|
||||
use cell::{cell_set, range_fill, range_set_values};
|
||||
pub(crate) use cell::{check_arg_counts, input_to_cell, json_to_input, parse_typed_formula};
|
||||
pub(crate) use cell::{check_arg_counts, input_to_cell, is_formula_input, json_to_input, parse_typed_formula};
|
||||
#[cfg(test)]
|
||||
pub(crate) use clipboard::parse_tsv;
|
||||
use clipboard::{clear_clipboard, copy, cut, paste};
|
||||
@@ -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> {
|
||||
@@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Values",
|
||||
[],
|
||||
None,
|
||||
"{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas), parse?: bool (default true: a string goes through typed input, so \"05.04.2026\" becomes a date and \"2.500\" a number, by en-US rules; false stores every other string as the text it is, so a dd.mm.yyyy date or a thousands separator survives)}",
|
||||
"{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas), parse?: bool (default true: a string goes through typed input, so \"05.04.2026\" becomes a date and \"2.500\" a number, by en-US rules; false stores every other string as the text it is, so a dd.mm.yyyy date or a thousands separator survives, a leading ' is kept and an empty string clears the cell)} → {cells, convertedCount, converted: [{cell, input, stored}] (up to 20 of the strings typed input read as a number, date, time, boolean or error, so a script can notice)}",
|
||||
has_doc,
|
||||
range_set_values
|
||||
),
|
||||
@@ -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
|
||||
),
|
||||
@@ -183,6 +186,8 @@ fn undo(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
label = e.label.clone();
|
||||
d.wb = e.wb;
|
||||
d.selection = e.selection;
|
||||
// Step by step: a rename and a new sheet undone together match up only one step apart.
|
||||
super::extra::keep_view_modes(d, &cur_wb);
|
||||
crate::push_history(&mut d.redo, label.clone(), cur_wb, &d.wb, cur_selection);
|
||||
}
|
||||
super::extra::keep_watch_list(&now, &mut d.wb);
|
||||
@@ -203,6 +208,7 @@ fn redo(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
label = e.label.clone();
|
||||
d.wb = e.wb;
|
||||
d.selection = e.selection;
|
||||
super::extra::keep_view_modes(d, &cur_wb);
|
||||
crate::push_history(&mut d.undo, label.clone(), cur_wb, &d.wb, cur_selection);
|
||||
}
|
||||
super::extra::keep_watch_list(&now, &mut d.wb);
|
||||
@@ -213,6 +219,13 @@ fn redo(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
// ---------------------------------------------------------------- clear
|
||||
|
||||
/// The rows of `range` an applied filter hides (rows hidden by hand are not among them).
|
||||
fn filtered_rows(sh: &gridcraft_model::Sheet, range: RangeRef) -> std::collections::BTreeSet<u32> {
|
||||
sh.rows.range(range.start.row..=range.end.row).filter(|(r, info)| info.hidden && sh.is_row_filtered(**r)).map(|(r, _)| *r).collect()
|
||||
}
|
||||
|
||||
/// The Clear commands (Delete is Clear Contents). As in Excel, the rows an applied filter hides
|
||||
/// are left as they are; rows hidden by hand are cleared.
|
||||
fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let ranges = target_ranges(s, p)?;
|
||||
@@ -223,7 +236,10 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
|
||||
d.wb.sheet(sheet).is_some_and(|sh| {
|
||||
ranges.iter().any(|r| {
|
||||
r.intersection(&sh.used_range().unwrap_or(RangeRef::cell(r.start))).is_some_and(|r| {
|
||||
sh.cells.iter_range(r).any(|(c, x)| x.value == Value::Bool(true) && super::extra::is_checkbox_cell(&d.wb, sh, c))
|
||||
let skip = filtered_rows(sh, r);
|
||||
sh.cells
|
||||
.iter_range(r)
|
||||
.any(|(c, x)| !skip.contains(&c.row) && x.value == Value::Bool(true) && super::extra::is_checkbox_cell(&d.wb, sh, c))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -237,8 +253,12 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
|
||||
},
|
||||
None => continue,
|
||||
};
|
||||
let cells: Vec<(CellRef, Cell)> =
|
||||
cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(r).map(|(c, x)| (c, x.clone())).collect()).unwrap_or_default();
|
||||
let skip = cx.wb.sheet(sheet).map(|sh| filtered_rows(sh, r)).unwrap_or_default();
|
||||
let cells: Vec<(CellRef, Cell)> = cx
|
||||
.wb
|
||||
.sheet(sheet)
|
||||
.map(|sh| sh.cells.iter_range(r).filter(|(c, _)| !skip.contains(&c.row)).map(|(c, x)| (c, x.clone())).collect())
|
||||
.unwrap_or_default();
|
||||
// Report every stored cell we touch, even beyond a million coordinates in a sparse
|
||||
// selection. Removed formula anchors must also reach calc so their spills disappear.
|
||||
cx.changed.extend(cells.iter().map(|(c, _)| (sheet, *c)));
|
||||
@@ -247,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 {
|
||||
@@ -273,16 +294,17 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let cleared = |c: CellRef| r.contains(c) && !skip.contains(&c.row);
|
||||
if matches!(what, "all" | "comments") {
|
||||
sh.comments.retain(|c, _| !r.contains(*c));
|
||||
sh.comments.retain(|c, _| !cleared(*c));
|
||||
}
|
||||
if matches!(what, "all" | "hyperlinks") {
|
||||
sh.hyperlinks.retain(|c, _| !r.contains(*c));
|
||||
sh.hyperlinks.retain(|c, _| !cleared(*c));
|
||||
}
|
||||
if matches!(what, "all" | "formats") {
|
||||
let mut unmerged = false;
|
||||
sh.merges.retain(|m| {
|
||||
let keep = !r.contains_range(m);
|
||||
let keep = !r.contains_range(m) || skip.range(m.start.row..=m.end.row).next().is_some();
|
||||
unmerged |= !keep;
|
||||
keep
|
||||
});
|
||||
|
||||
@@ -5,24 +5,36 @@ 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 {
|
||||
input.starts_with('=')
|
||||
|| (input.len() > 1
|
||||
&& (input.starts_with('+') || input.starts_with('-'))
|
||||
&& gridcraft_core::parse::parse_number_text(input).is_none()
|
||||
&& input.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '('))
|
||||
}
|
||||
|
||||
/// What typing `input` into a cell produces (constant or formula, plus an automatic number
|
||||
/// format). Inherits the destination's cell, row or column style, including its number
|
||||
/// format, before parsing. Errors when the formula can't be parsed.
|
||||
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 }));
|
||||
}
|
||||
let is_formula = is_formula_input(input);
|
||||
if is_formula && gridcraft_model::python::is_py_formula(input) {
|
||||
return Err(EngineError::Other(PY_FORMULA.into()));
|
||||
}
|
||||
let is_formula = input.starts_with('=')
|
||||
|| (input.len() > 1
|
||||
&& (input.starts_with('+') || input.starts_with('-'))
|
||||
&& gridcraft_core::parse::parse_number_text(input).is_none()
|
||||
&& input.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '('));
|
||||
if is_formula {
|
||||
let body = input.strip_prefix('=').unwrap_or(input);
|
||||
let expr = wb.name_call_case(parse_typed_formula(body)?);
|
||||
@@ -74,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,
|
||||
@@ -142,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() });
|
||||
}
|
||||
@@ -183,6 +195,22 @@ pub(crate) fn json_to_input(v: &Json) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// How many converted strings `range.setValues` reports back, and how much of each input and
|
||||
/// stored text: the report is a diagnostic, so a big write must not put a second copy of its data
|
||||
/// in the result.
|
||||
const MAX_REPORTED_CONVERSIONS: usize = 20;
|
||||
const MAX_REPORTED_CHARS: usize = 32;
|
||||
|
||||
/// `text` cut to [`MAX_REPORTED_CHARS`] characters for the conversion report, with `…` when it was
|
||||
/// cut. Counting characters keeps a multi-byte input whole.
|
||||
fn clipped(text: &str) -> String {
|
||||
let mut out: String = text.chars().take(MAX_REPORTED_CHARS).collect();
|
||||
if out.len() < text.len() {
|
||||
out.push('…');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let start = target_range(s, p)?.start;
|
||||
@@ -200,6 +228,10 @@ pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
return Err(bad("range.setValues", "too many rows"));
|
||||
}
|
||||
let mut count = 0;
|
||||
// The strings typed input read as something other than the text given, for the result: a
|
||||
// caller can notice a "05.04.2026" that became a date without reading the file back (#2255).
|
||||
let mut converted: Vec<(CellRef, String)> = Vec::new();
|
||||
let mut converted_count = 0;
|
||||
edit(s, |cx| {
|
||||
for (ri, row) in rows.iter().enumerate() {
|
||||
let cols: Vec<&Json> = match row.as_array() {
|
||||
@@ -214,23 +246,47 @@ 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.
|
||||
Json::String(text) if !parse && !text.starts_with('=') => {
|
||||
let mut style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
|
||||
// Line breaks still turn on Wrap Text, as typed input does.
|
||||
if text.contains('\n') && !cx.wb.styles.get(style).align.wrap {
|
||||
style = cx.wb.styles.derive(style, |s| s.align.wrap = true);
|
||||
if text.is_empty() {
|
||||
// An empty string clears the cell, as `null` and the default path do:
|
||||
// an empty text cell is not blank, and a script clearing cells with
|
||||
// `""` would leave cells `COUNTA` counts. The cell keeps its formatting.
|
||||
input_to_cell("", sheet, at, &mut cx.wb)?
|
||||
} else {
|
||||
let mut style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
|
||||
// Line breaks still turn on Wrap Text, as typed input does.
|
||||
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, py: None })
|
||||
}
|
||||
Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style })
|
||||
}
|
||||
_ => input_to_cell(&input, sheet, at, &mut cx.wb)?,
|
||||
};
|
||||
// A string that became a formula was written as one (`=`), which is deliberate;
|
||||
// only a number (dates and times included), boolean or error is a reading of the
|
||||
// string and worth reporting.
|
||||
if let Some(c) = &cell
|
||||
&& matches!(v, Json::String(_))
|
||||
&& c.formula.is_none()
|
||||
&& matches!(c.value, Value::Number(_) | Value::Bool(_) | Value::Error(_))
|
||||
{
|
||||
converted_count += 1;
|
||||
if converted.len() < MAX_REPORTED_CONVERSIONS {
|
||||
converted.push((at, input.clone()));
|
||||
}
|
||||
}
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
match cell {
|
||||
Some(c) => sh.set_cell(at, c),
|
||||
@@ -245,7 +301,17 @@ pub(super) fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"cells": count}))
|
||||
let d = s.doc()?;
|
||||
let reported: Vec<Json> =
|
||||
d.wb.sheet(sheet)
|
||||
.map(|sh| {
|
||||
converted
|
||||
.iter()
|
||||
.map(|(at, input)| json!({"cell": at.a1(), "input": clipped(input), "stored": clipped(&crate::display::cell_text(&d.wb, sh, *at))}))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Ok(json!({"cells": count, "convertedCount": converted_count, "converted": reported}))
|
||||
}
|
||||
|
||||
pub(super) fn range_fill(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
@@ -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
|
||||
)))
|
||||
}
|
||||
|
||||
@@ -192,6 +192,74 @@ fn set_values_can_store_strings_as_the_text_they_are() {
|
||||
assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A9").unwrap())).align.wrap);
|
||||
}
|
||||
|
||||
/// The result names the strings typed input read as something else, so a script notices without
|
||||
/// reading the file back (#2255). A formula and a leading apostrophe are not conversions.
|
||||
#[test]
|
||||
fn set_values_reports_the_strings_typed_input_read() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let row = json!(["05.04.2026", "02/11/2026", "2.500", "4.25 %", "=1+1", "'keep"]);
|
||||
let r = s.execute("range.setValues", json!({"range": "A1", "values": [row.clone()]})).unwrap();
|
||||
assert_eq!(r["cells"], 6, "{r}");
|
||||
assert_eq!(r["convertedCount"], 4, "{r}");
|
||||
assert_eq!(r["converted"][0], json!({"cell": "A1", "input": "05.04.2026", "stored": "5/4/2026"}), "{r}");
|
||||
assert_eq!(r["converted"][1], json!({"cell": "B1", "input": "02/11/2026", "stored": "2/11/2026"}), "{r}");
|
||||
assert_eq!(r["converted"][2], json!({"cell": "C1", "input": "2.500", "stored": "2.5"}), "{r}");
|
||||
assert_eq!(r["converted"][3], json!({"cell": "D1", "input": "4.25 %", "stored": "4.25%"}), "{r}");
|
||||
// The report is a diagnostic, not a second copy of the data: a long input is cut.
|
||||
let long = s.execute("range.setValues", json!({"range": "J1", "values": [["9".repeat(100)]]})).unwrap();
|
||||
assert_eq!(long["convertedCount"], 1, "{long}");
|
||||
let input = long["converted"][0]["input"].as_str().unwrap_or_default();
|
||||
assert_eq!(input.chars().count(), 33, "32 characters and the ellipsis: {input}");
|
||||
assert!(input.ends_with('…'), "{input}");
|
||||
// Nothing is read with `parse: false`, so nothing is reported.
|
||||
let verbatim = s.execute("range.setValues", json!({"range": "A3", "parse": false, "values": [row]})).unwrap();
|
||||
assert_eq!(verbatim["convertedCount"], 0, "{verbatim}");
|
||||
assert_eq!(verbatim["converted"], json!([]), "{verbatim}");
|
||||
}
|
||||
|
||||
/// An empty string clears the cell with `parse: false` too, as `null` and the default path do: a
|
||||
/// script clearing cells with "" must not leave cells `COUNTA` counts.
|
||||
#[test]
|
||||
fn set_values_parse_false_clears_an_empty_string() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1:B1", "values": [["keep", "keep"]]})).unwrap();
|
||||
s.execute("home.bold", json!({"range": "A1"})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "A1", "parse": false, "values": [["", ""]]})).unwrap();
|
||||
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
|
||||
assert_eq!(get(&mut s, "A1")["type"], "empty");
|
||||
assert_eq!(get(&mut s, "B1")["type"], "empty");
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.sheet(0).unwrap();
|
||||
assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A1").unwrap())).font.bold, "clearing with \"\" keeps the formatting");
|
||||
assert_eq!(s.execute("formulas.evaluate", json!({"formula": "=COUNTA(A1:B1)"})).unwrap(), json!(0.0));
|
||||
}
|
||||
|
||||
/// The text `parse: false` stores is still text after the workbook is saved and reopened, and the
|
||||
/// default's reading of a date survives as a date (#2255).
|
||||
#[test]
|
||||
fn set_values_text_survives_an_xlsx_roundtrip() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let values = json!([["05.04.2026", "2.500", "4.25 %", "=1+1", 3, true]]);
|
||||
s.execute("range.setValues", json!({"range": "A1", "parse": false, "values": values})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "A3", "values": [["05.04.2026"]]})).unwrap();
|
||||
let bytes = crate::io::save_bytes(&s.doc().unwrap().wb, "book.xlsx").unwrap();
|
||||
let (wb, _) = crate::io::open_bytes("book.xlsx", &bytes).unwrap();
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
for (col, want) in [(0, "05.04.2026"), (1, "2.500"), (2, "4.25 %")] {
|
||||
assert_eq!(sh.value(CellRef::new(0, col)), Value::text(want), "column {col} is the text it was given");
|
||||
}
|
||||
assert_eq!(sh.value(CellRef::new(0, 3)), Value::number(2.0), "=1+1 is still a formula");
|
||||
assert_eq!(sh.value(CellRef::new(0, 4)), Value::number(3.0));
|
||||
assert_eq!(sh.value(CellRef::new(0, 5)), Value::Bool(true));
|
||||
// The default read the date, and the file keeps it as one.
|
||||
let at = CellRef::new(2, 0);
|
||||
assert_eq!(crate::display::cell_text(&wb, sh, at), "5/4/2026");
|
||||
assert_eq!(wb.styles.get(sh.style_id(at)).num_fmt.as_str(), "m/d/yyyy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_array_entry() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -28,7 +28,7 @@ use objects::{align, arrange, rotate, selection_pane, shape_fill, shape_outline}
|
||||
pub(crate) use scripts::has_scripts;
|
||||
use scripts::{all_scripts, delete_script, new_script, record, run_script, stop_action, trust_scripts};
|
||||
use window::{arrange_all, custom_views, hide_window, new_window, ruler, set_view, split, switch_windows, unhide_window, watch_window};
|
||||
pub(crate) use window::{keep_watch_list, window_param};
|
||||
pub(crate) use window::{keep_view_modes, keep_watch_list, window_param};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -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
|
||||
),
|
||||
@@ -310,7 +310,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
new_cell_style
|
||||
),
|
||||
cmd!(query "home.addIns", "Add-ins", ["Home", "Add-ins"], None, "{} → GridCraft extends through MCP and scripts instead of add-ins", always, |_, _| Ok(json!({"addIns": [], "hint": "Use gridcraft-cli mcp or Automate › scripts"}))),
|
||||
cmd!(query "formulas.recentlyUsed", "Recently Used", ["Formulas", "Function Library"], None, "{} → recently used functions in this workbook", has_doc, recently_used),
|
||||
cmd!(query "formulas.recentlyUsed", "Recently Used", ["Formulas", "Function Library"], None, "{} → the functions most recently entered in formulas, newest first", has_doc, recently_used),
|
||||
cmd!(query "formulas.financial", "Financial", ["Formulas", "Function Library"], None, "{}", always, |s, _| s.execute("formulas.functions", json!({"category": "Financial"}))),
|
||||
cmd!(query "formulas.logical", "Logical", ["Formulas", "Function Library"], None, "{}", always, |s, _| s.execute("formulas.functions", json!({"category": "Logical"}))),
|
||||
cmd!(query "formulas.text", "Text", ["Formulas", "Function Library"], None, "{}", always, |s, _| s.execute("formulas.functions", json!({"category": "Text"}))),
|
||||
|
||||
@@ -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 {
|
||||
@@ -122,25 +123,10 @@ pub(super) fn new_cell_style(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Formulas › Recently Used: the functions most recently entered in a formula, newest first
|
||||
/// ([`crate::Prefs::recent_functions`]).
|
||||
pub(super) fn recently_used(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
for (id, p) in s.journal.iter().rev() {
|
||||
if id != "cell.set" {
|
||||
continue;
|
||||
}
|
||||
let input = p.get("input").and_then(Json::as_str).unwrap_or("");
|
||||
if let Ok(e) = gridcraft_formula::parse(input.trim_start_matches('=')) {
|
||||
for f in e.functions() {
|
||||
if !seen.iter().any(|x| x == f) {
|
||||
seen.push(f.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if seen.len() >= 10 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(json!(seen))
|
||||
Ok(json!(s.prefs.recent_functions))
|
||||
}
|
||||
|
||||
pub(super) fn use_in_formula(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
use super::{Json, Result, SCRIPTS_NAME, Session, Workbook, bad, bool_param, edit, json, stash_get, stash_map, stash_set, str_param};
|
||||
use super::{EngineError, Json, Result, SCRIPTS_NAME, Session, Workbook, bad, bool_param, edit, json, stash_get, stash_map, stash_set, str_param};
|
||||
|
||||
pub(super) fn record(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if bool_param(p, "stop").unwrap_or(false) || s.recording.is_some() {
|
||||
let Some(start) = s.recording.take() else { return Ok(json!({"recording": false})) };
|
||||
let cmds: Vec<Json> =
|
||||
s.journal.iter().skip(start).filter(|(id, _)| !id.starts_with("automate.")).map(|(id, p)| json!({"command": id, "params": p})).collect();
|
||||
let cmds: Vec<Json> = s
|
||||
.journal
|
||||
.iter()
|
||||
.skip(start)
|
||||
.filter(|(id, _)| !id.starts_with("automate.") && !crate::record::NOT_RECORDED.contains(&id.as_str()))
|
||||
.map(|(id, p)| json!({"command": id, "params": p}))
|
||||
.collect();
|
||||
let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("Script {}", start));
|
||||
s.execute("automate.newScript", json!({"name": name, "commands": cmds}))?;
|
||||
return Ok(json!({"recording": false, "saved": name, "steps": cmds.len()}));
|
||||
@@ -104,12 +109,16 @@ pub(super) fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
let _sandbox = crate::io::ScriptSandbox::enter();
|
||||
let mut n = 0;
|
||||
// What ran, for a recorder in progress: the recording keeps these steps with the run, so
|
||||
// editing or deleting the script before the recording stops does not change what it saved.
|
||||
let mut ran_steps: Vec<(String, Json)> = Vec::new();
|
||||
for st in &steps {
|
||||
let id = st["command"].as_str().unwrap_or("");
|
||||
if id.starts_with("automate.") {
|
||||
continue;
|
||||
}
|
||||
let before = s.ui_requests.len();
|
||||
let wb_before = s.doc().ok().map(|d| (d.uid, d.wb.clone()));
|
||||
let ran = s.execute(id, st["params"].clone());
|
||||
// A step that asks the host to open a URL leaves the app: drop the request and stop.
|
||||
if s.ui_requests.get(before..).is_some_and(|new| new.iter().any(|r| matches!(r, crate::UiRequest::OpenUrl(_)))) {
|
||||
@@ -120,17 +129,43 @@ pub(super) fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
});
|
||||
return Err(bad("automate.runScript", format!("scripts can't open links (`{id}`)")));
|
||||
}
|
||||
ran?;
|
||||
let result = ran?;
|
||||
n += 1;
|
||||
if s.recorder.is_some() {
|
||||
// The recorder's rule for each step, as for a command run on its own: a menu-style
|
||||
// step that only opened a dialog is left out (playing it back would stop at the
|
||||
// dialog), and a toggle keeps the state it set.
|
||||
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(id, st["params"].clone(), &result)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.recorder.is_some() {
|
||||
s.script_ran = Some(ran_steps);
|
||||
}
|
||||
Ok(json!({"ran": n}))
|
||||
}
|
||||
|
||||
/// Automate › Stop Action: rolls back the undo groups open on the active workbook, so Undo and
|
||||
/// Redo work there again.
|
||||
/// Redo work there again. A macro being debugged on that workbook is stopped with them.
|
||||
pub(super) fn stop_action(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
// Inside another command the open groups are that command's own (an action in progress):
|
||||
// a step of it that stopped them would pull the workbook from under the rest of its run.
|
||||
if s.depth() > 1 {
|
||||
return Err(EngineError::Disabled("automate.stopAction".into(), "Stop Action runs on its own, not as a step of a script or macro.".into()));
|
||||
}
|
||||
let uid = s.doc()?.uid;
|
||||
Ok(json!({"stopped": s.abort_groups(uid)}))
|
||||
let mut out = json!({"stopped": s.abort_groups(uid)});
|
||||
// A macro being debugged here had its group among them: its run is over.
|
||||
if let Some((name, wrote_files)) = crate::macros::debug::end_stopped(s, uid) {
|
||||
out["macro"] = json!(name);
|
||||
out["message"] = json!(format!(
|
||||
"Macro '{name}', which was being debugged, was stopped and rolled back. {}",
|
||||
crate::macros::run::rolled_back(&wrote_files)
|
||||
));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(super) fn delete_script(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
@@ -38,6 +38,35 @@ pub(crate) fn keep_watch_list(from: &Workbook, to: &mut std::sync::Arc<Workbook>
|
||||
wb.names.extend(keep);
|
||||
}
|
||||
|
||||
/// Normal, Page Layout and Page Break Preview belong to the window, not to the edits: Excel doesn't
|
||||
/// undo a switch between them, and `set_view` is no undo step. So undo and redo keep each sheet's
|
||||
/// current view when they swap in another snapshot of the workbook, and a view kept this way
|
||||
/// doesn't make a saved workbook dirty.
|
||||
pub(crate) fn keep_view_modes(d: &mut crate::DocState, now: &Workbook) {
|
||||
// Sheets have no stable id. A name missing from `now` is a rename being undone or redone, which
|
||||
// keeps the sheet count and position: the sheet is the one at the same index, under a name the
|
||||
// restored snapshot doesn't have.
|
||||
let renamed = |i: usize| {
|
||||
(now.sheets.len() == d.wb.sheets.len()).then(|| now.sheets.get(i)).flatten().filter(|sh| !d.wb.sheets.iter().any(|s| s.name == sh.name))
|
||||
};
|
||||
let current = |i: usize, name: &str| now.sheets.iter().find(|sh| sh.name == name).or_else(|| renamed(i)).map(|sh| sh.view);
|
||||
let changed: Vec<(usize, SheetViewMode)> =
|
||||
d.wb.sheets.iter().enumerate().filter_map(|(i, sh)| current(i, &sh.name).filter(|v| *v != sh.view).map(|v| (i, v))).collect();
|
||||
if changed.is_empty() {
|
||||
return;
|
||||
}
|
||||
let dirty = d.is_dirty();
|
||||
let wb = std::sync::Arc::make_mut(&mut d.wb);
|
||||
for (i, v) in changed {
|
||||
if let Some(sh) = wb.sheet_mut(i) {
|
||||
sh.view = v;
|
||||
}
|
||||
}
|
||||
if !dirty {
|
||||
d.saved = d.wb.clone();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn custom_views(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let mut views = stash_map(&d.wb, VIEWS_NAME);
|
||||
|
||||
@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path} (.xlsx, .csv, .tsv, .json, .html, or .pdf: a PDF of the active sheet)", has_doc, save_as),
|
||||
cmd!(query "file.saveBytes", "Encode Workbook", [], None, "{format?: xlsx|csv|tsv|json|html} → {base64}", has_doc, save_bytes),
|
||||
cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{force?: bool, document?: bool} (closes the active window, or with `document` the workbook and all its windows; the workbook closes with its last window)", has_doc, close),
|
||||
cmd!(noundo "file.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?}", has_doc, |s, p| export(s, p, "csv")),
|
||||
cmd!(noundo "file.exportCsv", "Export as CSV", ["File", "Export"], None, "{path, sheet?, values?: bool} → {path, bytes}: writes the cells as displayed, like Excel's Save As CSV; `values: true` writes raw values instead (full-precision numbers, ISO 8601 dates and times, TRUE/FALSE)", has_doc, |s, p| export(s, p, "csv")),
|
||||
cmd!(noundo "file.exportHtml", "Save as Web Page", ["File", "Export"], None, "{path}", has_doc, |s, p| export(s, p, "html")),
|
||||
cmd!("file.properties", "Properties", ["File"], None, "{title?, subject?, author?, company?, keywords?, description?}", has_doc, properties),
|
||||
cmd!(
|
||||
@@ -191,7 +191,11 @@ fn export(s: &mut Session, p: &Json, ext: &str) -> Result<Json> {
|
||||
wb.set_active_sheet(target_sheet(s, p)?);
|
||||
}
|
||||
let target = if path.ends_with(&format!(".{ext}")) { path.clone() } else { format!("{path}.{ext}") };
|
||||
let bytes = crate::io::save_bytes(&wb, &target)?;
|
||||
let bytes = if ext == "csv" && bool_param(p, "values").unwrap_or(false) {
|
||||
crate::io::csv_values_bytes(&wb)?
|
||||
} else {
|
||||
crate::io::save_bytes(&wb, &target)?
|
||||
};
|
||||
crate::io::write_file(&target, &bytes)?;
|
||||
Ok(json!({"path": target, "bytes": bytes.len()}))
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
+1650
-20
File diff suppressed because it is too large
Load Diff
@@ -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(&mut cx.wb, &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,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
//! Macros: typed command lists that run as one undo step. See [`crate::macros`] for the model,
|
||||
//! tokens, runner, debugger, recorder and policy. Automate scripts (`automate.*`) are a separate,
|
||||
//! older feature with their own storage; these commands neither read nor change it. A recording
|
||||
//! keeps the steps a script run ran while it recorded, as the run reported them.
|
||||
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, bool_param, cmd, edit, has_doc, str_param};
|
||||
use crate::macros::recorder::RecordingStart;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "macro.list", "List Macros", [], None, "{} → [{name, description, shortcut?, steps, inputs: [names], onConflict, permissions}]", has_doc, list),
|
||||
cmd!(query "macro.get", "Get Macro", [], None, "{name} → the macro: {v, name, description, shortcut?, inputs, steps, output, permissions}", has_doc, get),
|
||||
cmd!(
|
||||
"macro.save",
|
||||
"Save Macro",
|
||||
[],
|
||||
None,
|
||||
"{macro: {name, description?, shortcut?, inputs?: [{name, kind: cell|range, rows: [min, max], cols: [min, max], header, colTypes: [number|text|date|bool|any], nonEmpty, defaultRef?}], steps: [{type: command, id, params, enabled?, breakpoint?, note?} | {type: repeat, over, of: rows|columns, max, body} | {type: if, cell, op: eq|ne|gt|ge|lt|le|empty|notEmpty|contains, value, then, else} | {type: stop, message, rollback?}], output?: {anchor?, onConflict: ask|fail|overwrite|shiftDown|shiftRight}, permissions?: [command ids]} (or those fields at the top level), replace?: bool (default false: saving over a macro with the name is refused)} → {name, steps}",
|
||||
has_doc,
|
||||
save
|
||||
),
|
||||
cmd!("macro.delete", "Delete Macro", [], None, "{name}", has_doc, delete),
|
||||
cmd!("macro.rename", "Rename Macro", [], None, "{name, to}", has_doc, rename),
|
||||
cmd!(
|
||||
"macro.run",
|
||||
"Run Macro",
|
||||
[],
|
||||
None,
|
||||
"{name, inputs?: {Input: \"B2:D9\"} | \"B2:D9\", onConflict?: ask|fail|overwrite|shiftDown|shiftRight, overwrite?: bool, approve?: bool, timeLimit?: seconds} runs as one undo step; any error rolls it all back → {macro, ran, inputs, stopped?, shifted?}",
|
||||
has_doc,
|
||||
run
|
||||
),
|
||||
cmd!(
|
||||
query "macro.check",
|
||||
"Check Macro",
|
||||
[],
|
||||
None,
|
||||
"{name, inputs?, onConflict?, overwrite?, timeLimit?: seconds} validates the inputs and dry-runs the macro on the workbook (then puts everything back) → {ok (macro.run with these parameters would go ahead without a question), errors?, needsApproval?, onConflict, inputs, output?, conflicts, footprint}",
|
||||
has_doc,
|
||||
check
|
||||
),
|
||||
cmd!(
|
||||
noundo "macro.record",
|
||||
"Record Macro",
|
||||
["View", "Macros"],
|
||||
None,
|
||||
"{stop?: bool, cancel?: bool (ends a recording without saving it), relative?: bool (default true; when starting), name?, shortcut?: \"Ctrl+Shift+M\" | \"M\", description?} toggles recording; when starting, the name (new and valid) and the shortcut (free) are checked and kept for the save, and without a name the macro gets the next MacroN; when stopping, a name or description given then wins (a taken name gets a suffix); with relative references the selection when it starts is the example input → {recording, name?, relative?, input?} or {recording: false, saved, steps, dropped, skipped, inputs, shortcutDropped? (the shortcut another macro took while recording)}",
|
||||
has_doc,
|
||||
record
|
||||
),
|
||||
cmd!(
|
||||
"macro.debug",
|
||||
"Step Into",
|
||||
[],
|
||||
None,
|
||||
"{action: start|step|continue|runTo|stop|state, name, inputs?, onConflict?, overwrite?, approve?, timeLimit?: seconds per action (start, as macro.run), step: path (runTo), keep?: bool (stop), breakpoints?: [\"3\", \"2.1\"] (steps of the macro), breakpointsOnly?: bool (the list replaces the steps' own breakpoints)} debugs a macro one step at a time; stop keeps the changes as one undo step or rolls them back. Runs on its own, never as a step of a script or macro. Until it finishes or stops, its workbook runs only macro.debug, queries, selection and sheet changes, Go To, Find, Copy, zoom, Switch Window, Close and Stop Action (other workbooks stay editable) → {state: paused|finished|stopped|failed, current: {step, kind, command?, params?, iteration?}, lastParams, executed, changed (at most 500), changedCells, atBreakpoint?, shifted?, wroteFiles?, message?, error?}",
|
||||
has_doc,
|
||||
|s, p| crate::macros::debug::debug(s, p)
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn name_param<'a>(cmd: &str, p: &'a Json) -> Result<&'a str> {
|
||||
str_param(p, "name").filter(|n| !n.trim().is_empty()).ok_or_else(|| bad(cmd, "missing `name`"))
|
||||
}
|
||||
|
||||
/// The index of macro `name` in the active workbook, or an error that says there is none.
|
||||
fn index_of(s: &Session, name: &str) -> Result<usize> {
|
||||
crate::macros::find(&s.doc()?.wb, name).ok_or_else(|| EngineError::Other(format!("There is no macro called '{name}'.")))
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let wb = &s.doc()?.wb;
|
||||
Ok(Json::Array(
|
||||
wb.macros
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let mut o = json!({
|
||||
"name": m.name,
|
||||
"description": m.description,
|
||||
"steps": m.step_count(),
|
||||
"inputs": m.inputs.iter().map(|i| i.name.clone()).collect::<Vec<_>>(),
|
||||
"onConflict": m.output.on_conflict.name(),
|
||||
"permissions": m.permissions,
|
||||
});
|
||||
if let Some(k) = &m.shortcut {
|
||||
o["shortcut"] = json!(k);
|
||||
}
|
||||
o
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
fn get(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let i = index_of(s, name_param("macro.get", p)?)?;
|
||||
let wb = &s.doc()?.wb;
|
||||
Ok(wb.macros.get(i).map(crate::macros::macro_json).unwrap_or(Json::Null))
|
||||
}
|
||||
|
||||
fn save(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let v = p.get("macro").filter(|m| m.is_object()).unwrap_or(p);
|
||||
let m = crate::macros::parse_macro(v).map_err(|e| bad("macro.save", e))?;
|
||||
// Replacing is asked for, never assumed: a save with a name in use is refused.
|
||||
let replace = match p.get("replace") {
|
||||
None | Some(Json::Null) => false,
|
||||
Some(Json::Bool(b)) => *b,
|
||||
Some(_) => return Err(bad("macro.save", "`replace` must be true or false")),
|
||||
};
|
||||
let (name, steps) = (m.name.clone(), m.step_count());
|
||||
edit(s, |cx| {
|
||||
if let Some(kept) = crate::macros::kept_name(&cx.wb, &m.name) {
|
||||
return Err(EngineError::Other(taken_by_kept(&kept)));
|
||||
}
|
||||
match crate::macros::find(&cx.wb, &m.name) {
|
||||
Some(i) if !replace => {
|
||||
let existing = cx.wb.macros.get(i).map_or(m.name.as_str(), |o| o.name.as_str());
|
||||
return Err(EngineError::Other(format!("A macro called '{existing}' already exists. Pass `replace: true` to replace it.")));
|
||||
}
|
||||
Some(i) => {
|
||||
if let Some(old) = cx.wb.macros.get_mut(i) {
|
||||
*old = m;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
crate::macros::check_room(&cx.wb).map_err(EngineError::Other)?;
|
||||
cx.wb.macros.push(m);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"name": name, "steps": steps}))
|
||||
}
|
||||
|
||||
/// The refusal of a name that a macro kept in a newer format has.
|
||||
fn taken_by_kept(name: &str) -> String {
|
||||
format!(
|
||||
"A macro called '{name}' made by a newer version of GridCraft is kept in this workbook; this version can't change it. Choose another name."
|
||||
)
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let i = index_of(s, name_param("macro.delete", p)?)?;
|
||||
edit(s, |cx| {
|
||||
if i < cx.wb.macros.len() {
|
||||
cx.wb.macros.remove(i);
|
||||
}
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
||||
fn rename(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let i = index_of(s, name_param("macro.rename", p)?)?;
|
||||
let to = str_param(p, "to").map(str::trim).filter(|t| !t.is_empty()).ok_or_else(|| bad("macro.rename", "missing `to`"))?.to_string();
|
||||
if to.chars().any(char::is_control) {
|
||||
return Err(bad("macro.rename", "a macro name can't hold line breaks or other control characters"));
|
||||
}
|
||||
let wb = &s.doc()?.wb;
|
||||
if crate::macros::find(wb, &to).is_some_and(|j| j != i) {
|
||||
return Err(EngineError::Other(format!("A macro called '{to}' already exists.")));
|
||||
}
|
||||
if let Some(kept) = crate::macros::kept_name(wb, &to) {
|
||||
return Err(EngineError::Other(taken_by_kept(&kept)));
|
||||
}
|
||||
edit(s, |cx| {
|
||||
let Some(m) = cx.wb.macros.get_mut(i) else { return Ok(()) };
|
||||
let mut renamed = m.clone();
|
||||
renamed.name = to.clone();
|
||||
// The whole check, size included: a longer name can take a macro past what a file holds.
|
||||
crate::macros::check(&renamed).map_err(EngineError::Other)?;
|
||||
*m = renamed;
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"name": to}))
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let name = name_param("macro.run", p)?;
|
||||
// While recording: the steps a recording keeps for this run, taken from the macro as it runs,
|
||||
// so saving or deleting the macro before the recording stops doesn't change what it saved.
|
||||
let inline = if s.is_recording() {
|
||||
let wb = &s.doc()?.wb;
|
||||
crate::macros::find(wb, name).and_then(|i| wb.macros.get(i)).and_then(crate::macros::recorder::inline_steps)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let out = crate::macros::run::run(s, name, p)?;
|
||||
if s.is_recording() {
|
||||
// `None` when the macro's steps don't stand on their own: the recording leaves the run out.
|
||||
s.script_ran = inline;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn check(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let name = name_param("macro.check", p)?;
|
||||
crate::macros::run::check(s, name, p)
|
||||
}
|
||||
|
||||
fn record(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
check_record_params(s.is_recording(), p)?;
|
||||
// `cancel` ends a recording without saving it: the way out of a recording whose macro
|
||||
// cannot be saved (a name the check refuses, a full workbook).
|
||||
if bool_param(p, "cancel").unwrap_or(false) {
|
||||
let was = s.stop_recording().is_some();
|
||||
s.macro_recording = None;
|
||||
return Ok(json!({"recording": false, "cancelled": was}));
|
||||
}
|
||||
if bool_param(p, "stop").unwrap_or(false) || s.is_recording() {
|
||||
return stop_recording(s, p);
|
||||
}
|
||||
start_recording(s, p, bool_param(p, "relative").unwrap_or(true))
|
||||
}
|
||||
|
||||
/// Refuses parameters `macro.record` would ignore (`null` counts as not given): `relative` and
|
||||
/// `shortcut` when stopping (they are chosen when recording starts), `stop: false` while
|
||||
/// recording (the call stops it), anything beside `cancel: true`, and parameters of the wrong
|
||||
/// kind. `name` and `description` count when starting and when stopping (stopping wins).
|
||||
fn check_record_params(recording: bool, p: &Json) -> Result<()> {
|
||||
let given = |k: &str| p.get(k).filter(|v| !v.is_null());
|
||||
for k in ["stop", "cancel", "relative"] {
|
||||
if given(k).is_some_and(|v| !v.is_boolean()) {
|
||||
return Err(bad("macro.record", format!("`{k}` must be true or false")));
|
||||
}
|
||||
}
|
||||
for k in ["name", "description", "shortcut"] {
|
||||
if given(k).is_some_and(|v| !v.is_string()) {
|
||||
return Err(bad("macro.record", format!("`{k}` must be text")));
|
||||
}
|
||||
}
|
||||
if given("cancel").and_then(Json::as_bool) == Some(true) {
|
||||
if let Some(k) = ["stop", "relative", "name", "description", "shortcut"].into_iter().find(|k| given(k).is_some()) {
|
||||
return Err(bad("macro.record", format!("`cancel: true` ends the recording without saving it, so `{k}` would be ignored")));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let stop = given("stop").and_then(Json::as_bool);
|
||||
if recording && stop == Some(false) {
|
||||
return Err(bad(
|
||||
"macro.record",
|
||||
"a recording is in progress, and this call stops it: pass `stop: true`, or `cancel: true` to end it unsaved",
|
||||
));
|
||||
}
|
||||
if (recording || stop == Some(true))
|
||||
&& let Some(k) = ["relative", "shortcut"].into_iter().find(|k| given(k).is_some())
|
||||
{
|
||||
return Err(bad("macro.record", format!("`{k}` is chosen when recording starts")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Starts recording. `name`, `shortcut` and `description` (from the Record Macro dialog) are
|
||||
/// checked now, so a bad name or a taken shortcut is refused before anything is recorded.
|
||||
fn start_recording(s: &mut Session, p: &Json, relative: bool) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let name = match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
|
||||
Some(n) => Some(crate::macros::check_new_name(&d.wb, n).map_err(|e| bad("macro.record", e))?),
|
||||
None => None,
|
||||
};
|
||||
let shortcut = match str_param(p, "shortcut").map(str::trim).filter(|k| !k.is_empty()) {
|
||||
Some(k) => {
|
||||
let letter = crate::macros::parse_shortcut(k)
|
||||
.ok_or_else(|| bad("macro.record", "`shortcut` must be one letter from A to Z (used as Ctrl+Shift+letter)"))?;
|
||||
if let Some(why) = crate::macros::shortcut_conflict(&d.wb, name.as_deref().unwrap_or(""), letter) {
|
||||
return Err(bad("macro.record", why));
|
||||
}
|
||||
Some(crate::macros::shortcut_text(letter))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let description = match str_param(p, "description") {
|
||||
Some(t) if t.chars().count() > gridcraft_model::MAX_MACRO_TEXT => {
|
||||
return Err(bad("macro.record", format!("`description` can be at most {} characters long", gridcraft_model::MAX_MACRO_TEXT)));
|
||||
}
|
||||
Some(t) => Some(t.to_string()),
|
||||
None => None,
|
||||
};
|
||||
let mut start = RecordingStart::new(d, relative);
|
||||
(start.name, start.shortcut, start.description) = (name.clone(), shortcut, description);
|
||||
let input = start.selection.current().a1();
|
||||
s.start_recording();
|
||||
s.macro_recording = Some(start);
|
||||
let mut out = if relative { json!({"recording": true, "relative": true, "input": input}) } else { json!({"recording": true, "relative": false}) };
|
||||
if let Some(n) = name {
|
||||
out["name"] = json!(n);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Stops recording and saves what was recorded as a macro, in the workbook where recording
|
||||
/// started. When saving fails (a bad name, a full workbook, a macro being debugged there) the
|
||||
/// recording goes on, so stopping again keeps what was recorded; when that workbook was closed,
|
||||
/// the recording ends unsaved.
|
||||
fn stop_recording(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let Some(rec) = s.stop_recording() else { return Ok(json!({"recording": false})) };
|
||||
let start = s.macro_recording.take();
|
||||
let at = match &start {
|
||||
Some(st) => s
|
||||
.docs
|
||||
.iter()
|
||||
.position(|d| d.uid == st.doc)
|
||||
.ok_or_else(|| EngineError::Other("The workbook where recording started was closed, so the recording was not saved.".into()))?,
|
||||
None => s.active_index(),
|
||||
};
|
||||
let saved = save_recording(s, p, &rec, start.clone(), at);
|
||||
if saved.is_err() {
|
||||
s.recorder = Some(rec);
|
||||
s.macro_recording = start;
|
||||
}
|
||||
saved
|
||||
}
|
||||
|
||||
/// Saves the recording into the workbook at index `at`.
|
||||
fn save_recording(s: &mut Session, p: &Json, rec: &crate::record::Recorder, start: Option<RecordingStart>, at: usize) -> Result<Json> {
|
||||
let d = s.docs.get(at).ok_or(EngineError::NoDocument)?;
|
||||
// A macro being debugged there could still roll the saved macro back with its run.
|
||||
if s.macro_debug.as_ref().is_some_and(|m| m.locks(d.uid)) {
|
||||
return Err(EngineError::Disabled("macro.record".into(), crate::macros::debug::BUSY.into()));
|
||||
}
|
||||
// A recording started without `macro.record` (`Session::start_recording`) takes its example
|
||||
// input from the workbook as it is now.
|
||||
let start = start.unwrap_or_else(|| RecordingStart::new(d, true));
|
||||
let (doc, wb) = (d.uid, d.wb.clone());
|
||||
// A name given when stopping wins over the one given when starting; either one, if another
|
||||
// macro has it, gets a suffix, so the save never replaces a macro.
|
||||
let name = match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).or(start.name.as_deref()) {
|
||||
Some(n) => crate::macros::unique_name(&wb, n),
|
||||
None => crate::macros::next_name(&wb),
|
||||
};
|
||||
let mut converted = crate::macros::recorder::convert(&name, &start, &rec.steps, &rec.ran);
|
||||
// Steps past the macro limit or its size, and ones recorded in another workbook, are left
|
||||
// out and counted with the ones the recorder dropped.
|
||||
let mut cut = converted.cut.saturating_add(converted.mac.steps.len().saturating_sub(gridcraft_model::MAX_MACRO_STEPS));
|
||||
converted.mac.steps.truncate(gridcraft_model::MAX_MACRO_STEPS);
|
||||
if let Some(desc) = str_param(p, "description").or(start.description.as_deref()) {
|
||||
converted.mac.description = desc.chars().take(gridcraft_model::MAX_MACRO_TEXT).collect();
|
||||
}
|
||||
cut = cut.saturating_add(crate::macros::fit_size(&mut converted.mac));
|
||||
// The shortcut, unless another macro took it while recording; then it is left off and the
|
||||
// result says so.
|
||||
let mut shortcut_dropped = None;
|
||||
if let Some(k) = &start.shortcut {
|
||||
if crate::macros::parse_shortcut(k).is_some_and(|c| crate::macros::shortcut_conflict(&wb, &name, c).is_none()) {
|
||||
converted.mac.shortcut = Some(k.clone());
|
||||
} else {
|
||||
shortcut_dropped = Some(k.clone());
|
||||
}
|
||||
}
|
||||
let steps = converted.mac.steps.len();
|
||||
let inputs: Vec<String> = converted.mac.inputs.iter().map(|i| i.name.clone()).collect();
|
||||
// Saved into that workbook even when another one is active now.
|
||||
let active = s.active_index();
|
||||
s.set_active(at);
|
||||
let saved = s.execute("macro.save", json!({"macro": crate::macros::macro_json(&converted.mac), "replace": false}));
|
||||
s.set_active(active);
|
||||
saved?;
|
||||
// The person recorded it, so it runs without the approval a macro that came with a file
|
||||
// needs, until it changes.
|
||||
if let Some(d) = s.docs.iter().find(|d| d.uid == doc)
|
||||
&& let Some(m) = crate::macros::find(&d.wb, &name).and_then(|i| d.wb.macros.get(i))
|
||||
{
|
||||
let key = (doc, crate::macros::fingerprint(m));
|
||||
s.macro_approvals.insert(key);
|
||||
}
|
||||
let mut out = json!({"recording": false, "saved": name, "steps": steps, "dropped": rec.dropped.saturating_add(cut), "skipped": converted.skipped, "inputs": inputs});
|
||||
if let Some(k) = shortcut_dropped {
|
||||
out["shortcutDropped"] = json!(k);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -2,8 +2,8 @@ use super::layout::{DENSE_BLOCK, SheetLayout};
|
||||
use super::objects::draw_cell_picture;
|
||||
use super::text::{TextPlacement, draw_text};
|
||||
use super::{
|
||||
BorderLine, BorderStyle, Borders, CellRef, Color, Font, Page, PatternType, Point, RangeRef, Rgb, Sheet, Style, StyleId, Underline, Workbook,
|
||||
split_sheet,
|
||||
BorderLine, BorderStyle, Borders, CellRef, Color, Font, Page, PatternType, Point, RangeRef, Rgb, Sheet, Style, StyleId, Underline, Value,
|
||||
Workbook, split_sheet,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- drawing
|
||||
@@ -280,6 +280,7 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
|
||||
|| merges.iter().any(|m| m.start == *c)
|
||||
|| !sh.value(*c).is_empty()
|
||||
|| l.style.borders != Borders::default()
|
||||
|| l.style.num_fmt.as_str() == "checkbox"
|
||||
})
|
||||
.collect();
|
||||
let rect_of = |c: CellRef| -> (f32, f32, f32, f32, bool) {
|
||||
@@ -343,6 +344,8 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
|
||||
}
|
||||
if let Some(picture) = sh.cell_pictures.get(c) {
|
||||
draw_cell_picture(pg, &picture.data, (x, y, w, h), b.k);
|
||||
} else if let Some(on) = checkbox_state(l, &sh.value(*c)) {
|
||||
draw_checkbox(pg, (x, y, w, h), b.k, on);
|
||||
} else {
|
||||
draw_text(pg, b, TextPlacement { cell: *c, look: l, rect: (x, y, w, h), merged, merges: &merges, limits: (bx, bx + bw) });
|
||||
}
|
||||
@@ -350,6 +353,37 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
|
||||
pg.restore();
|
||||
}
|
||||
|
||||
/// A checkbox cell prints TRUE/FALSE (and empty) as a box, as on screen (#2972); other values
|
||||
/// print as text.
|
||||
fn checkbox_state(l: &Look, v: &Value) -> Option<bool> {
|
||||
if l.style.num_fmt.as_str() != "checkbox" || l.cf.as_ref().is_some_and(|cf| cf.hide_value) {
|
||||
return None;
|
||||
}
|
||||
match v {
|
||||
Value::Bool(on) => Some(*on),
|
||||
Value::Empty => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A checkbox centred in the cell: white with a grey outline, or green with a white tick.
|
||||
fn draw_checkbox(pg: &mut Page, (x, y, w, h): (f32, f32, f32, f32), k: f32, on: bool) {
|
||||
let s = (13.2 * k).min(w - 2.0 * k).min(h - 2.0 * k);
|
||||
if s <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let (bx, by) = (x + (w - s) / 2.0, y + (h - s) / 2.0);
|
||||
let corners = [[bx, by], [bx + s, by], [bx + s, by + s], [bx, by + s]];
|
||||
if on {
|
||||
let green = [0x10, 0x7C, 0x41];
|
||||
pg.polygon(&corners, Some(green), Some((green, 0.75 * k)));
|
||||
let tick = [[bx + 0.22 * s, by + 0.52 * s], [bx + 0.42 * s, by + 0.72 * s], [bx + 0.78 * s, by + 0.30 * s]];
|
||||
pg.polyline(&tick, 0.14 * s, [0xFF, 0xFF, 0xFF], &[]);
|
||||
} else {
|
||||
pg.polygon(&corners, Some([0xFF, 0xFF, 0xFF]), Some(([110, 110, 110], 0.75 * k)));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct PendingPdfLink {
|
||||
pub(super) page: usize,
|
||||
pub(super) sheet: usize,
|
||||
|
||||
@@ -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();
|
||||
@@ -565,3 +733,129 @@ fn rotated_wrapped_text_prints_rotated() {
|
||||
assert_eq!(up + down, blocks.len(), "a header line printed flat: {blocks:?}");
|
||||
assert!((1..=3).contains(&up) && (1..=3).contains(&down), "up {up}, down {down}: {blocks:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkbox_cells_print_as_boxes_not_true_false() {
|
||||
// #2972: a checkbox cell printed its value as TRUE or FALSE.
|
||||
let mut s = s();
|
||||
s.execute("insert.checkbox", json!({"range": "A1:A2"})).unwrap();
|
||||
s.execute("cell.toggleCheckbox", json!({"cell": "A2"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C1", "input": "TRUE"})).unwrap();
|
||||
let (pdf, _) = pdf_of(&mut s, json!({}));
|
||||
assert!(!contains(&pdf, "(FALSE) Tj"), "the unchecked box printed as FALSE");
|
||||
// Only the plain TRUE in C1 prints as text; the checked box is a filled box.
|
||||
let text = String::from_utf8_lossy(&pdf);
|
||||
assert_eq!(text.matches("(TRUE) Tj").count(), 1, "the checked box printed as TRUE");
|
||||
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,9 +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(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit) {
|
||||
super::edit::rewrite_all_formulas(wb, target, e);
|
||||
/// 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)?;
|
||||
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 };
|
||||
@@ -243,15 +230,6 @@ pub(super) fn rewrite(wb: &mut gridcraft_model::Workbook, 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);
|
||||
}
|
||||
@@ -268,6 +246,7 @@ pub(super) fn rewrite(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Inserting or deleting cells moves the copied block away from the stored source range, so, like
|
||||
@@ -296,7 +275,7 @@ fn check_push_off_sheet(s: &Session, sheet: usize, axis: Axis, at: u32, count: u
|
||||
if let Some(u) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
|
||||
let last = if axis == Axis::Rows { u.end.row } else { u.end.col };
|
||||
if last >= at && last as u64 + count as u64 >= limit as u64 {
|
||||
return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into()));
|
||||
return Err(EngineError::Other("To prevent possible loss of data, GridCraft cannot shift nonblank cells off of the worksheet.".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -325,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(&mut cx.wb, &name, &Edit::Insert { axis, at, count });
|
||||
rewrite(cx, &name, &Edit::Insert { axis, at, count })?;
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
})?;
|
||||
@@ -390,7 +369,7 @@ fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
||||
}
|
||||
shift_sheet_features(sh, axis, at, count, false);
|
||||
}
|
||||
rewrite(&mut cx.wb, &name, &Edit::Delete { axis, at, count });
|
||||
rewrite(cx, &name, &Edit::Delete { axis, at, count })?;
|
||||
}
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
@@ -442,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(&mut cx.wb, &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();
|
||||
{
|
||||
@@ -480,14 +459,14 @@ fn move_columns(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
for cell in touched {
|
||||
cx.touch(sheet, cell);
|
||||
}
|
||||
rewrite(&mut cx.wb, &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(&mut cx.wb, &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),
|
||||
@@ -518,8 +497,8 @@ 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()) {
|
||||
return Err(EngineError::Other("To prevent possible loss of data, Excel cannot shift nonblank cells off of the worksheet.".into()));
|
||||
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()));
|
||||
}
|
||||
}
|
||||
let right = shift == "right";
|
||||
@@ -532,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(&mut cx.wb, &name, &e);
|
||||
rewrite(cx, &name, &e)?;
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
})?;
|
||||
@@ -567,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(&mut cx.wb, &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 {
|
||||
@@ -199,7 +225,7 @@ fn phase_one(tableau: &mut Tableau, normalized: &[(Vec<f64>, Sense, f64)], budge
|
||||
return Err(Outcome::Infeasible);
|
||||
}
|
||||
for i in 0..tableau.rows.len() {
|
||||
if !tableau.basis.get(i).is_some_and(|basic| *basic >= tableau.decision_cols + tableau.slack_cols) {
|
||||
if tableau.basis.get(i).is_none_or(|basic| *basic < tableau.decision_cols + tableau.slack_cols) {
|
||||
continue;
|
||||
}
|
||||
let pivot_col = 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
+105
-37
@@ -15,7 +15,7 @@
|
||||
//! noncharacter, which XML carries but real text does not use, so the escape costs nothing on
|
||||
//! ordinary text. Legacy `_sc_` items are read as they are.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use gridcraft_model::{DefinedName, Workbook};
|
||||
@@ -102,15 +102,29 @@ pub fn get(wb: &Workbook, key: &str) -> Option<String> {
|
||||
assemble(&parts)
|
||||
}
|
||||
|
||||
/// What a listing found: the first [`MAX_ITEMS`] items or keys, and how many more there were.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Listing<T> {
|
||||
pub found: T,
|
||||
/// Items past the cap, not listed. A caller says so rather than letting them vanish.
|
||||
pub more: usize,
|
||||
}
|
||||
|
||||
/// The items whose key starts with `prefix` (`"macro_"`), by lower-cased key, read in one pass
|
||||
/// over the names: the first [`MAX_ITEMS`] of [`keys`], without those over the size limit.
|
||||
pub fn items(wb: &Workbook, prefix: &str) -> BTreeMap<String, String> {
|
||||
group(wb, prefix)
|
||||
.into_iter()
|
||||
.filter(|(_, parts)| parts.is_item())
|
||||
.take(MAX_ITEMS)
|
||||
.filter_map(|(k, parts)| assemble(&parts).map(|text| (k, text)))
|
||||
.collect()
|
||||
/// over the names: the first [`MAX_ITEMS`] items that can be read, and the count of the readable
|
||||
/// ones past them. An item over the size limit is neither listed nor counted, so it takes no
|
||||
/// place another item could have. ([`keys`] lists keys without reading the items.)
|
||||
pub fn items(wb: &Workbook, prefix: &str) -> Listing<BTreeMap<String, String>> {
|
||||
let mut listing: Listing<BTreeMap<String, String>> = Listing::default();
|
||||
for (k, parts) in group(wb, prefix) {
|
||||
let Some(text) = parts.is_item().then(|| assemble(&parts)).flatten() else { continue };
|
||||
if listing.found.len() < MAX_ITEMS {
|
||||
listing.found.insert(k, text);
|
||||
} else {
|
||||
listing.more = listing.more.saturating_add(1);
|
||||
}
|
||||
}
|
||||
listing
|
||||
}
|
||||
|
||||
/// The text of the item held by `parts`, as [`get`] reads it.
|
||||
@@ -169,28 +183,44 @@ fn unescape(s: &str) -> String {
|
||||
|
||||
/// Stores `text` under `key`, replacing what was there (in either format). Empty text removes it.
|
||||
pub fn set(wb: &mut Workbook, key: &str, text: &str) -> Result<()> {
|
||||
check_key(key)?;
|
||||
if text.chars().count() > MAX_ITEM_CHARS {
|
||||
return Err(EngineError::Other(format!("The data is too large to keep in the workbook ({} MB at most).", MAX_ITEM_CHARS / (1024 * 1024))));
|
||||
set_many(wb, &[(key.to_string(), text.to_string())])
|
||||
}
|
||||
|
||||
/// Stores every `(key, text)` of `items` as [`set`] would, with the checks done before anything
|
||||
/// changes and one pass over the names for all of them, so writing a thousand items into a
|
||||
/// workbook with many thousands of names stays near-linear.
|
||||
pub fn set_many(wb: &mut Workbook, items: &[(String, String)]) -> Result<()> {
|
||||
let mut planned = Vec::new();
|
||||
let mut keys: HashSet<String> = HashSet::new();
|
||||
for (key, text) in items {
|
||||
check_key(key)?;
|
||||
if text.chars().count() > MAX_ITEM_CHARS {
|
||||
return Err(EngineError::Other(format!(
|
||||
"The data is too large to keep in the workbook ({} MB at most).",
|
||||
MAX_ITEM_CHARS / (1024 * 1024)
|
||||
)));
|
||||
}
|
||||
// Two items under one key (names compare without case) would leave two hidden names of
|
||||
// one name, of which an xlsx file keeps one: a caller's mistake, refused.
|
||||
if !keys.insert(key.to_ascii_lowercase()) {
|
||||
return Err(EngineError::Other(format!("`{key}` is given twice.")));
|
||||
}
|
||||
for (i, formula) in chunk_formulas(text).into_iter().enumerate() {
|
||||
planned.push(DefinedName { name: format!("{PREFIX}{key}_{i}"), scope: None, formula, comment: "GridCraft data".into(), hidden: true });
|
||||
}
|
||||
}
|
||||
let formulas = chunk_formulas(text);
|
||||
// A visible name of the item's shape is someone's own (see `item_of`); a hidden name beside
|
||||
// it with the same name would be the one an xlsx file drops. Refuse before changing anything,
|
||||
// in one pass over the names (a file can carry many thousands).
|
||||
let chunks = formulas.len() as u64;
|
||||
let taken = |n: &&DefinedName| {
|
||||
n.scope.is_none() && !n.hidden && parse_name(&n.name).is_some_and(|(k, i)| k.eq_ignore_ascii_case(key) && i.is_some_and(|i| i < chunks))
|
||||
};
|
||||
if let Some(n) = wb.names.iter().find(taken) {
|
||||
// A visible name of an item's shape is someone's own (see `item_of`); a hidden name beside
|
||||
// it with the same name would be the one an xlsx file drops. Refuse before changing anything.
|
||||
let names: HashSet<String> = planned.iter().map(|n| n.name.to_ascii_lowercase()).collect();
|
||||
if let Some(n) = wb.names.iter().find(|n| n.scope.is_none() && !n.hidden && names.contains(&n.name.to_ascii_lowercase())) {
|
||||
return Err(EngineError::Other(format!(
|
||||
"The workbook has a defined name '{}', which GridCraft needs for its own data. Rename it first.",
|
||||
n.name
|
||||
)));
|
||||
}
|
||||
remove(wb, key);
|
||||
for (i, formula) in formulas.into_iter().enumerate() {
|
||||
wb.names.push(DefinedName { name: format!("{PREFIX}{key}_{i}"), scope: None, formula, comment: "GridCraft data".into(), hidden: true });
|
||||
}
|
||||
// What was there under these keys goes, in either format, then the new chunks come.
|
||||
wb.names.retain(|n| !item_of(n).is_some_and(|(k, _)| keys.contains(&k.to_ascii_lowercase())));
|
||||
wb.names.extend(planned);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -211,8 +241,11 @@ pub fn set_json(wb: &mut Workbook, key: &str, v: &Json) -> Result<()> {
|
||||
/// The keys of the items stored in the workbook that start with `prefix` (`"macro_"`), sorted
|
||||
/// and lower-cased: the first [`MAX_ITEMS`] of them. To read them all, [`items`] takes one pass
|
||||
/// where a [`get`] per key would take one each.
|
||||
pub fn keys(wb: &Workbook, prefix: &str) -> Vec<String> {
|
||||
group(wb, prefix).into_iter().filter(|(_, parts)| parts.is_item()).map(|(k, _)| k).take(MAX_ITEMS).collect()
|
||||
pub fn keys(wb: &Workbook, prefix: &str) -> Listing<Vec<String>> {
|
||||
let mut found: Vec<String> = group(wb, prefix).into_iter().filter(|(_, parts)| parts.is_item()).map(|(k, _)| k).collect();
|
||||
let more = found.len().saturating_sub(MAX_ITEMS);
|
||||
found.truncate(MAX_ITEMS);
|
||||
Listing { found, more }
|
||||
}
|
||||
|
||||
/// Whether the workbook holds an item under `name` (compared without case): a hidden workbook-scope
|
||||
@@ -417,10 +450,10 @@ mod tests {
|
||||
// Only a later chunk: no item, not listed, and the legacy name is still read.
|
||||
wb.names.push(name("_gc_orphan_2", "\"c\""));
|
||||
assert_eq!(get(&wb, "orphan"), None);
|
||||
assert_eq!(keys(&wb, "orphan"), Vec::<String>::new());
|
||||
assert_eq!(keys(&wb, "orphan").found, Vec::<String>::new());
|
||||
wb.names.push(name("_sc_orphan", "\"old\""));
|
||||
assert_eq!(get(&wb, "orphan").as_deref(), Some("old"));
|
||||
assert_eq!(keys(&wb, "orphan"), ["orphan"]);
|
||||
assert_eq!(keys(&wb, "orphan").found, ["orphan"]);
|
||||
// Chunks out of order, a duplicate, junk around the literals, an unterminated literal.
|
||||
wb.names.push(name("_gc_mix_1", "\"b\"&A1&"));
|
||||
wb.names.push(name("_gc_mix_0", "SUM(\"a\")"));
|
||||
@@ -468,7 +501,7 @@ mod tests {
|
||||
let own = DefinedName { hidden: false, ..name("_gc_watch_0", "\"mine\"") };
|
||||
wb.names.push(own.clone());
|
||||
assert_eq!(get(&wb, "watch"), None);
|
||||
assert!(keys(&wb, "").is_empty());
|
||||
assert!(keys(&wb, "").found.is_empty());
|
||||
// Writing the item would put a hidden name of the same name beside it, and an xlsx file
|
||||
// keeps only one of them: refused, with the names as they were.
|
||||
let err = set(&mut wb, "watch", "theirs").unwrap_err().to_string();
|
||||
@@ -482,6 +515,18 @@ mod tests {
|
||||
assert_eq!(wb.names, vec![own]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_many_refuses_one_key_twice() {
|
||||
// Two items under one key, in any case, would leave two hidden names of one name.
|
||||
let mut wb = Workbook::new();
|
||||
wb.names.push(name("_gc_other_0", "\"stays\""));
|
||||
let err = set_many(&mut wb, &[("k".into(), "a".into()), ("K".into(), "b".into())]).unwrap_err().to_string();
|
||||
assert!(err.contains("given twice"), "{err}");
|
||||
assert_eq!(wb.names.len(), 1, "nothing changed");
|
||||
set_many(&mut wb, &[("k".into(), "a".into()), ("k2".into(), "b".into())]).unwrap();
|
||||
assert_eq!((get(&wb, "k").as_deref(), get(&wb, "K2").as_deref(), get(&wb, "other").as_deref()), (Some("a"), Some("b"), Some("stays")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chunk_number_with_leading_zeros_is_another_name() {
|
||||
// `_gc_watch_00` is not chunk 0 under another spelling: a hidden one is not read, and a
|
||||
@@ -489,7 +534,7 @@ mod tests {
|
||||
let mut wb = Workbook::new();
|
||||
wb.names.push(name("_gc_watch_00", "\"theirs\""));
|
||||
assert_eq!(get(&wb, "watch"), None);
|
||||
assert!(keys(&wb, "").is_empty());
|
||||
assert!(keys(&wb, "").found.is_empty());
|
||||
let own = DefinedName { hidden: false, ..name("_gc_watch_00", "\"mine\"") };
|
||||
wb.names.push(own.clone());
|
||||
set(&mut wb, "watch", "ours").unwrap();
|
||||
@@ -505,7 +550,7 @@ mod tests {
|
||||
set(&mut wb, "macro_2", "b").unwrap();
|
||||
set(&mut wb, "macro_1", &"a".repeat(9_000)).unwrap();
|
||||
set(&mut wb, "scripts", "{}").unwrap();
|
||||
assert_eq!(keys(&wb, "macro_"), ["macro_1", "macro_2"]);
|
||||
assert_eq!(keys(&wb, "macro_").found, ["macro_1", "macro_2"]);
|
||||
assert_eq!(item_key("_gc_macro_1_3").as_deref(), Some("macro_1"));
|
||||
assert_eq!(item_key("_SC_Scripts").as_deref(), Some("scripts"));
|
||||
assert_eq!(item_key("Total"), None);
|
||||
@@ -525,7 +570,8 @@ mod tests {
|
||||
wb.names.push(name("_gc_macro_orphan_1", "\"no chunk 0\""));
|
||||
wb.names.push(name("_gc_other_0", "\"not a macro\""));
|
||||
NAME_VISITS.with(|v| v.set(0));
|
||||
let all = items(&wb, "MACRO_");
|
||||
let listing = items(&wb, "MACRO_");
|
||||
let all = &listing.found;
|
||||
assert_eq!(NAME_VISITS.with(|v| v.get()), wb.names.len(), "one pass over the names");
|
||||
assert_eq!(all.len(), MAX_ITEMS);
|
||||
let first: Vec<&String> = all.keys().take(2).collect();
|
||||
@@ -535,19 +581,41 @@ mod tests {
|
||||
NAME_VISITS.with(|v| v.set(0));
|
||||
let listed = keys(&wb, "macro_");
|
||||
assert_eq!(NAME_VISITS.with(|v| v.get()), wb.names.len());
|
||||
assert_eq!(listed, all.keys().cloned().collect::<Vec<_>>(), "keys and items agree");
|
||||
assert_eq!(listed.found, all.keys().cloned().collect::<Vec<_>>(), "keys and items agree");
|
||||
assert_eq!((listing.more, listed.more), (10_002, 10_002), "the rest are counted, not lost");
|
||||
|
||||
// Under the cap every item is there, read as `get` reads it.
|
||||
wb.names.retain(|n| !n.name.starts_with("_gc_macro_0") && !n.name.starts_with("_gc_macro_1"));
|
||||
let few = items(&wb, "macro_");
|
||||
let few = items(&wb, "macro_").found;
|
||||
let want: BTreeMap<String, String> =
|
||||
[("macro_big", "b".repeat(9_000)), ("macro_legacy", "old".into())].into_iter().map(|(k, t)| (k.to_string(), t)).collect();
|
||||
assert_eq!(few, want);
|
||||
for (k, text) in &few {
|
||||
assert_eq!(get(&wb, k).as_ref(), Some(text));
|
||||
}
|
||||
assert_eq!(keys(&wb, "macro_"), ["macro_big", "macro_legacy"]);
|
||||
assert_eq!(items(&wb, "").len(), 3, "the other item too");
|
||||
assert_eq!(keys(&wb, "macro_").found, ["macro_big", "macro_legacy"]);
|
||||
assert_eq!((items(&wb, "").found.len(), items(&wb, "").more), (3, 0), "the other item too, none past the cap");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_item_over_the_size_limit_takes_no_place_in_a_listing() {
|
||||
// One item too large to read sorts first and one sorts last, around MAX_ITEMS readable
|
||||
// ones: every readable item is listed, and neither oversized one is counted as more.
|
||||
let mut wb = Workbook::new();
|
||||
let oversized = |wb: &mut Workbook, key: &str| {
|
||||
for (i, formula) in chunk_formulas(&"x".repeat(MAX_ITEM_CHARS + 1)).into_iter().enumerate() {
|
||||
wb.names.push(name(&format!("_gc_{key}_{i}"), &formula));
|
||||
}
|
||||
};
|
||||
oversized(&mut wb, "macro_0");
|
||||
for i in 0..MAX_ITEMS {
|
||||
wb.names.push(name(&format!("_gc_macro_{i:05}_0"), &format!("\"m{i}\"")));
|
||||
}
|
||||
oversized(&mut wb, "macro_z");
|
||||
assert_eq!(get(&wb, "macro_0"), None, "too large to read");
|
||||
let listing = items(&wb, "macro_");
|
||||
assert_eq!((listing.found.len(), listing.more), (MAX_ITEMS, 0));
|
||||
assert!(listing.found.contains_key("macro_09999"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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.
|
||||
@@ -593,12 +596,12 @@ pub fn series(cx: &mut Ctx, sheet: usize, options: SeriesOptions<'_>) -> Result<
|
||||
};
|
||||
// The stop value bounds the series in the direction it runs (a growth by 0.5 falls).
|
||||
if let Some(s) = stop
|
||||
&& ((v >= prev && v > s) || (v < prev && v < s))
|
||||
&& ((v >= prev && round15(v) > s) || (v < prev && round15(v) < s))
|
||||
{
|
||||
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);
|
||||
@@ -806,6 +809,21 @@ mod tests {
|
||||
assert_eq!(sh.value(CellRef::new(1, 1)), Value::from("03/12/2023"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn series_keeps_value_equal_to_stop_after_float_drift() {
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", serde_json::json!({"cell": "A1", "input": "1"})).unwrap();
|
||||
s.execute("edit.fillSeries", serde_json::json!({"range": "A1:A6", "type": "linear", "step": 0.1, "stop": 1.3})).unwrap();
|
||||
s.execute("cell.set", serde_json::json!({"cell": "B1", "input": "0.1"})).unwrap();
|
||||
s.execute("edit.fillSeries", serde_json::json!({"range": "B1:B4", "type": "growth", "step": 3, "stop": 0.3})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
let col = |c: u32, n: u32| (0..n).map(|r| sh.value(CellRef::new(r, c))).collect::<Vec<_>>();
|
||||
let n = Value::number;
|
||||
assert_eq!(col(0, 6), vec![n(1.0), n(1.1), n(1.2), n(1.3), Value::Empty, Value::Empty]);
|
||||
assert_eq!(col(1, 4), vec![n(0.1), n(0.3), Value::Empty, Value::Empty]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flash_programs() {
|
||||
let p = learn(&[("John Smith".into(), "Smith, John".into())]).unwrap();
|
||||
|
||||
+184
-9
@@ -118,8 +118,143 @@ fn decrypt_message(name: &str, e: gridcraft_xlsx::DecryptError) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses bytes that aren't encrypted.
|
||||
/// 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);
|
||||
if sniffed == gridcraft_xlsx::Format::Xls {
|
||||
@@ -147,7 +282,8 @@ fn open_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook,
|
||||
}
|
||||
// Text named `.xlsx` is read as text.
|
||||
if sniffed == gridcraft_xlsx::Format::Xlsx || (kind == Some(FileKind::Xlsx) && sniffed != gridcraft_xlsx::Format::Csv) {
|
||||
let (wb, report) = gridcraft_xlsx::read_xlsx(bytes).map_err(|e| {
|
||||
// Google Sheets' export wrapper is read as the formula inside when GridCraft calculates it (#2871).
|
||||
let (wb, report) = gridcraft_xlsx::read_xlsx_with(bytes, &gridcraft_calc::is_known_function).map_err(|e| {
|
||||
EngineError::Other(match (&e, gridcraft_xlsx::oversized_part(bytes)) {
|
||||
(gridcraft_xlsx::IoError::TooLarge(_), Some((part, size))) => part_too_large_message(name, &part, size),
|
||||
_ => format!("We can't open '{name}': {e}"),
|
||||
@@ -160,7 +296,10 @@ fn open_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook,
|
||||
let mut wb: Workbook = serde_json::from_slice(bytes).map_err(|e| EngineError::Other(format!("not a GridCraft JSON workbook: {e}")))?;
|
||||
wb.styles.rebuild_index();
|
||||
if wb.sheets.is_empty() {
|
||||
// The file's macros are read after this (`macros::load`), so they come along.
|
||||
let macros = std::mem::take(&mut wb.macros_read);
|
||||
wb = Workbook::new();
|
||||
wb.macros_read = macros;
|
||||
}
|
||||
// The file's band placement and cell count are derived state: rebuild them from the
|
||||
// cells themselves, dropping what is outside the sheet grid (#1008).
|
||||
@@ -173,17 +312,17 @@ fn open_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook,
|
||||
}
|
||||
Some(FileKind::Tsv) => {
|
||||
let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', parse_values: !csv_as_text, ..Default::default() };
|
||||
let mut wb = gridcraft_xlsx::read_csv(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let (mut wb, report) = gridcraft_xlsx::read_csv_with_report(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
// Tab is what makes it a .tsv/.txt; saved as .csv it gets the CSV default.
|
||||
wb.csv_delimiter = None;
|
||||
rename_first_sheet(&mut wb, name);
|
||||
Ok((wb, vec![]))
|
||||
Ok((wb, report.warnings))
|
||||
}
|
||||
_ => {
|
||||
let opts = gridcraft_xlsx::CsvOptions { delimiter: 0, parse_values: !csv_as_text, ..Default::default() };
|
||||
let mut wb = gridcraft_xlsx::read_csv(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let (mut wb, report) = gridcraft_xlsx::read_csv_with_report(bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
rename_first_sheet(&mut wb, name);
|
||||
Ok((wb, vec![]))
|
||||
Ok((wb, report.warnings))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -238,9 +377,12 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8
|
||||
match kind {
|
||||
// The extension picks the package kind; only the macro-enabled kinds (.xlsm, .xltm) carry
|
||||
// the VBA project, so any other name saves without it, as Excel does.
|
||||
// Macros travel in hidden names.
|
||||
FileKind::Xlsx => {
|
||||
let named = crate::macros::with_names(wb)?;
|
||||
let book = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or_default();
|
||||
gridcraft_xlsx::write_xlsx_named(wb, gridcraft_xlsx::XlsxKind::from_path(path), book).map_err(|e| EngineError::Other(e.to_string()))
|
||||
gridcraft_xlsx::write_xlsx_named(named.as_ref().unwrap_or(wb), gridcraft_xlsx::XlsxKind::from_path(path), book)
|
||||
.map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
FileKind::Xls => {
|
||||
Err(EngineError::Other("Excel 97-2003 (.xls) is supported for import only. Save as .xlsx or another supported export format.".into()))
|
||||
@@ -250,7 +392,7 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8
|
||||
// A workbook read from CSV keeps the separator it was read with (a `;` file stays `;`).
|
||||
FileKind::Csv => wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).transpose().map(Option::unwrap_or_default),
|
||||
FileKind::Tsv => wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).transpose().map(Option::unwrap_or_default),
|
||||
FileKind::Json => serde_json::to_vec_pretty(wb).map_err(|e| EngineError::Other(e.to_string())),
|
||||
FileKind::Json => crate::macros::json_bytes(wb),
|
||||
FileKind::Html => to_html(wb, sheet).map(String::into_bytes),
|
||||
}
|
||||
}
|
||||
@@ -294,6 +436,39 @@ fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Result<Vec<u8>> {
|
||||
.map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
/// The active sheet as CSV of raw cell values, for automation: numbers at full precision, dates and
|
||||
/// times as ISO 8601, booleans `TRUE`/`FALSE`, errors as their text, text as stored. Save As CSV
|
||||
/// writes displayed text instead ([`save_bytes`]) and stays the default.
|
||||
pub fn csv_values_bytes(wb: &Workbook) -> Result<Vec<u8>> {
|
||||
let Some(sh) = wb.sheet(wb.active_sheet()) else { return Ok(Vec::new()) };
|
||||
gridcraft_xlsx::write_csv(sh, wb.csv_delimiter.unwrap_or(b','), |c| match sh.value(c) {
|
||||
gridcraft_core::Value::Number(n) if n.is_finite() => {
|
||||
let shape = crate::display::number_format(wb.styles.get(sh.style_id(c)).num_fmt.as_str()).date_shape(n);
|
||||
match shape.zip(gridcraft_core::date::datetime_from_serial(wb.date_system, n)) {
|
||||
Some(((date, time), d)) => iso(&d, date, time),
|
||||
None => n.to_string(),
|
||||
}
|
||||
}
|
||||
gridcraft_core::Value::Text(t) => t.to_string(),
|
||||
v => crate::display::value_text_as(wb, &v, "General"),
|
||||
})
|
||||
.map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
/// `date` and `time` say which parts the cell's format shows; a format with neither is not a date.
|
||||
fn iso(d: &gridcraft_core::date::DateTime, date: bool, time: bool) -> String {
|
||||
let day = format!("{:04}-{:02}-{:02}", d.year, d.month, d.day);
|
||||
let mut clock = format!("{:02}:{:02}:{:02}", d.hour, d.minute, d.second);
|
||||
if d.milli != 0 {
|
||||
clock.push_str(&format!(".{:03}", d.milli));
|
||||
}
|
||||
match (date, time) {
|
||||
(true, true) => format!("{day}T{clock}"),
|
||||
(false, true) => clock,
|
||||
_ => day,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn esc(s: &str) -> String {
|
||||
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
@@ -599,7 +774,7 @@ impl Drop for ScriptSandbox {
|
||||
/// thread is being torn down).
|
||||
fn script_sandbox_check(path: &str) -> Result<()> {
|
||||
if SCRIPT_SANDBOX.try_with(|c| c.get() > 0).unwrap_or(true) {
|
||||
return Err(EngineError::Other(format!("{path}: scripts can't read or write files")));
|
||||
return Err(EngineError::Other(format!("{path}: scripts, and macro steps without the permission, can't read or write files")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ fn copied(wb: &Workbook, range: &str) -> String {
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn save_fails_upfront_when_the_temp_name_is_taken() {
|
||||
// Another local user who can write the directory must not be able to point the
|
||||
@@ -290,6 +291,20 @@ fn csv_named_history_does_not_get_the_reserved_sheet_name() {
|
||||
assert_eq!(wb.sheets[0].name, "Sales");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_and_text_open_read_day_first_dates_and_report_a_guessed_order() {
|
||||
let may = gridcraft_core::date::serial_from_ymd(gridcraft_core::DateSystem::D1900, 2026, 5, 31).unwrap();
|
||||
let april = gridcraft_core::date::serial_from_ymd(gridcraft_core::DateSystem::D1900, 2026, 4, 3).unwrap();
|
||||
for file in ["dates.csv", "dates.txt"] {
|
||||
let (wb, warnings) = open_bytes(file, b"When\n31/05/2026\n03/04/2026\n").unwrap();
|
||||
assert_eq!(wb.sheet(0).unwrap().value(CellRef::new(1, 0)), gridcraft_core::Value::Number(may), "{file}");
|
||||
assert_eq!(wb.sheet(0).unwrap().value(CellRef::new(2, 0)), gridcraft_core::Value::Number(april), "{file}");
|
||||
assert!(warnings.is_empty(), "{file}: {warnings:?}");
|
||||
}
|
||||
let (_, warnings) = open_bytes("ambiguous.csv", b"When\n03/04/2026\n12/01/2026\n").unwrap();
|
||||
assert!(warnings.iter().any(|w| w.contains("column A") && w.contains("ambiguous")), "{warnings:?}");
|
||||
}
|
||||
|
||||
/// A save that can't be put in place (here a folder already has the workbook's name, as
|
||||
/// with a file another app holds locked) fails without leaving `<name>.sctmp` behind.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -446,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);
|
||||
@@ -560,3 +575,32 @@ fn content_wins_over_a_mismatched_extension() {
|
||||
assert_eq!(opened.sheet(0).unwrap().value(CellRef::new(0, 1)), gridcraft_core::Value::text("b"), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #2871: opening an XLSX reads Google Sheets' `IFERROR(__xludf.DUMMYFUNCTION("…"),cached)`
|
||||
/// wrapper as the formula inside when GridCraft calculates every function it calls (including
|
||||
/// the evaluator's own, such as ROW), and keeps the wrapper, and so the cached value, for a
|
||||
/// Google-only function.
|
||||
#[test]
|
||||
fn opening_google_sheets_export_unwraps_only_formulas_gridcraft_calculates() {
|
||||
use gridcraft_core::{CellRef, Value};
|
||||
let mut wb = Workbook::new();
|
||||
let sh = std::sync::Arc::make_mut(&mut wb.sheets[0]);
|
||||
let wrapped = [
|
||||
("A1", "IFERROR(__xludf.DUMMYFUNCTION(\"SUM(ROW(B1:B3))\"),6)", Value::Number(6.0)),
|
||||
("A2", "IFERROR(__xludf.DUMMYFUNCTION(\"GOOGLETRANSLATE(\"\"hi\"\",\"\"en\"\",\"\"fr\"\")\"),\"salut\")", Value::text("salut")),
|
||||
];
|
||||
for (at, text, value) in &wrapped {
|
||||
let cell = gridcraft_model::Cell {
|
||||
value: value.clone(),
|
||||
formula: Some(std::sync::Arc::new(gridcraft_model::Formula::new(text))),
|
||||
..Default::default()
|
||||
};
|
||||
sh.cells.set(CellRef::parse(at).unwrap(), cell);
|
||||
}
|
||||
let bytes = gridcraft_xlsx::write_xlsx(&wb).unwrap();
|
||||
let (opened, _) = open_bytes("google.xlsx", &bytes).unwrap();
|
||||
let text = |at: &str| opened.sheets[0].cells.get(CellRef::parse(at).unwrap()).and_then(|c| c.formula.as_ref()).map(|f| f.text.clone());
|
||||
assert_eq!(text("A1").as_deref(), Some("SUM(ROW(B1:B3))"));
|
||||
assert_eq!(text("A2").as_deref(), Some("IFERROR(__XLUDF.DUMMYFUNCTION(\"GOOGLETRANSLATE(\"\"hi\"\",\"\"en\"\",\"\"fr\"\")\"),\"salut\")"));
|
||||
assert_eq!(opened.sheets[0].cells.get(CellRef::parse("A2").unwrap()).map(|c| c.value.clone()), Some(Value::text("salut")));
|
||||
}
|
||||
|
||||
+144
-5
@@ -15,10 +15,12 @@ pub mod display;
|
||||
pub mod fill;
|
||||
pub mod io;
|
||||
pub mod links;
|
||||
pub mod macros;
|
||||
pub mod outline;
|
||||
pub mod paper;
|
||||
pub mod pivot;
|
||||
pub mod pivot_calc;
|
||||
pub mod recent;
|
||||
pub mod record;
|
||||
pub mod sample;
|
||||
pub mod selection;
|
||||
@@ -37,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;
|
||||
@@ -54,6 +57,12 @@ pub enum EngineError {
|
||||
NoDocument,
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
/// The command would replace something; run it again with `overwrite: true` to go ahead.
|
||||
#[error("confirm: {0} (run again with `overwrite: true`)")]
|
||||
Confirm(String),
|
||||
/// Active content (a macro) asks to run; run it again with `approve: true` to allow it.
|
||||
#[error("approve: {0} (run again with `approve: true`)")]
|
||||
Approve(String),
|
||||
#[error("internal error in `{0}` (the workbook was kept as it was): {1}")]
|
||||
Internal(String, String),
|
||||
}
|
||||
@@ -97,6 +106,9 @@ struct OpenGroup {
|
||||
selection: Selection,
|
||||
/// The redo stack when the group began (a rollback puts it back).
|
||||
redo: Vec<HistoryEntry>,
|
||||
/// Where the recording was when the group began (`None` when nothing was recording): a
|
||||
/// rollback takes back the steps recorded since, as it takes back their edits.
|
||||
recorded: Option<(u64, u64)>,
|
||||
}
|
||||
|
||||
/// Where the last [`Session::group_end`] or [`Session::group_rollback`] left its document: the
|
||||
@@ -203,6 +215,12 @@ pub struct DocState {
|
||||
/// workbook is opened from a file, carried by content pasted from such a workbook, and never
|
||||
/// changed by a trust decision.
|
||||
pub trust: Trust,
|
||||
/// While a macro's footprint is measured: every cell a command wrote, by sheet name, even
|
||||
/// where it wrote what was already there.
|
||||
pub touched: Option<Vec<(String, CellRef)>>,
|
||||
/// While a macro step runs: the structural edits its command made (the sheet's name, the
|
||||
/// edit), so the macro's inputs, output and Repeat ranges follow their cells as formulas do.
|
||||
pub edits: Option<Vec<(String, gridcraft_formula::adjust::Edit)>>,
|
||||
/// The references `edit.goTo` with `record: true` went to in this document, newest first,
|
||||
/// without names, each area written with its sheet (`Sheet1!B14:C22,Sheet1!F19:G30`).
|
||||
pub go_to_recent: Vec<String>,
|
||||
@@ -231,6 +249,10 @@ fn history_retained_bytes(older: &Workbook, newer: &Workbook) -> usize {
|
||||
let next = newer.sheets.get(index).map_or(&empty, |next| &next.cells);
|
||||
bytes = bytes.saturating_add(sheet.cells.retained_bytes_against(next));
|
||||
}
|
||||
// Snapshots share the macro list until a change to it; a snapshot with its own list keeps it.
|
||||
if !older.macros.shares(&newer.macros) {
|
||||
bytes = bytes.saturating_add(older.macros.estimated_bytes());
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
@@ -283,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());
|
||||
@@ -303,6 +327,8 @@ impl DocState {
|
||||
content_kind: None,
|
||||
scripts_trusted,
|
||||
trust,
|
||||
touched: None,
|
||||
edits: None,
|
||||
go_to_recent: vec![],
|
||||
}
|
||||
}
|
||||
@@ -368,6 +394,10 @@ pub struct Prefs {
|
||||
pub formula_bar: bool,
|
||||
/// The statistics the status bar shows for a selection (right-click the status bar).
|
||||
pub status_bar: StatusBarStats,
|
||||
/// Formulas › Recently Used: the functions most recently entered in a formula, newest first
|
||||
/// (at most [`recent::MAX`]; see [`recent`]). An application list, as in Excel.
|
||||
#[serde(deserialize_with = "recent::lenient")]
|
||||
pub recent_functions: Vec<String>,
|
||||
}
|
||||
|
||||
/// The selection statistics shown on the status bar, set with `view.statusBar`. Excel turns on
|
||||
@@ -402,6 +432,7 @@ impl Default for Prefs {
|
||||
user_dictionary: vec![],
|
||||
formula_bar: true,
|
||||
status_bar: StatusBarStats::default(),
|
||||
recent_functions: recent::defaults(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -446,6 +477,9 @@ pub struct Session {
|
||||
/// Serialized size of each journal entry, measured once when it is pushed, and their sum.
|
||||
journal_sizes: Vec<usize>,
|
||||
journal_bytes: usize,
|
||||
/// While a dry run runs (the footprint, `macro.check`), its commands are not journaled: the
|
||||
/// dry run is put back, and journaling it would evict the person's own oldest entries.
|
||||
pub(crate) journal_paused: u32,
|
||||
pub ui_requests: Vec<UiRequest>,
|
||||
/// Format painter source style (sticky when double-clicked).
|
||||
pub format_painter: Option<(Arc<Workbook>, usize, RangeRef, bool)>,
|
||||
@@ -460,6 +494,17 @@ pub struct Session {
|
||||
pub recorder: Option<record::Recorder>,
|
||||
/// How many `execute` calls are running (0 between top-level commands).
|
||||
depth: u32,
|
||||
/// The steps the last successful `automate.runScript` or `macro.run` ran while a recorder was
|
||||
/// on, for the recorder to keep with that step (see [`record::Recorder::ran`]); taken by the
|
||||
/// recording hook.
|
||||
pub(crate) script_ran: Option<Vec<(String, Json)>>,
|
||||
/// Macros the person allowed to run in this session: (document uid, the macro's content as
|
||||
/// canonical JSON, from [`macros::fingerprint`]).
|
||||
pub macro_approvals: std::collections::HashSet<(u64, String)>,
|
||||
/// The macro being debugged, between debugger actions.
|
||||
pub macro_debug: Option<macros::debug::Debugger>,
|
||||
/// Where the macro recorder started (beside `recorder`, its buffer).
|
||||
pub macro_recording: Option<macros::recorder::RecordingStart>,
|
||||
/// Draw tab tool: "" (select), "pen" or "eraser"; pen colour and width.
|
||||
pub draw_tool: String,
|
||||
pub draw_color: String,
|
||||
@@ -522,6 +567,17 @@ impl Session {
|
||||
pub fn doc_mut(&mut self) -> Result<&mut DocState> {
|
||||
self.active_mut().ok_or(EngineError::NoDocument)
|
||||
}
|
||||
/// The warning an edit of `at` on `sheet` gets from sheet protection, or `None` when the edit
|
||||
/// is allowed. Front ends ask before opening an editor, so typing into a locked cell never
|
||||
/// leaves a draft that can't be entered (and so blocks saving).
|
||||
pub fn locked_cell_message(&self, sheet: usize, at: CellRef) -> Option<String> {
|
||||
let d = self.active()?;
|
||||
let sh = d.wb.sheet(sheet)?;
|
||||
if !sh.is_protected() || !d.wb.styles.get(sh.style_id(at)).protection.locked {
|
||||
return None;
|
||||
}
|
||||
cmd::locked_cell_error(sh, at, &d.unlocked_ranges)
|
||||
}
|
||||
/// Opens a workbook in a new window, which becomes the active one.
|
||||
pub fn add_document(&mut self, d: DocState) -> usize {
|
||||
let uid = d.uid;
|
||||
@@ -535,6 +591,10 @@ impl Session {
|
||||
if i < self.docs.len() {
|
||||
let d = self.docs.remove(i);
|
||||
self.groups.retain(|g| g.doc != d.uid);
|
||||
// A macro being debugged in this workbook ends with it; its group just went.
|
||||
if self.macro_debug.as_ref().is_some_and(|m| m.doc == d.uid) {
|
||||
self.macro_debug = None;
|
||||
}
|
||||
self.windows.retain(|w| w.doc_uid != d.uid);
|
||||
if self.active_window.is_some_and(|w| self.window(w).is_none()) {
|
||||
self.activate_recent();
|
||||
@@ -547,6 +607,7 @@ impl Session {
|
||||
pub fn new_workbook(&mut self) -> usize {
|
||||
let n = (1..).find(|n| !self.docs.iter().any(|d| d.title == format!("Book{n}") && d.path.is_none())).unwrap_or(1);
|
||||
let mut wb = Workbook::new();
|
||||
wb.props.author = self.prefs.user_name.clone();
|
||||
for _ in 1..self.prefs.sheets_in_new_workbook.clamp(1, 255) {
|
||||
let name = wb.next_sheet_name();
|
||||
wb.sheets.push(Arc::new(gridcraft_model::Sheet::new(name)));
|
||||
@@ -586,6 +647,16 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cuts the journal back to its first `keep` entries, with its size bookkeeping.
|
||||
pub(crate) fn truncate_journal(&mut self, keep: usize) {
|
||||
if self.journal_sizes.len() == self.journal.len() {
|
||||
self.journal_sizes.truncate(keep);
|
||||
self.journal_bytes = self.journal_sizes.iter().sum();
|
||||
}
|
||||
self.journal.truncate(keep);
|
||||
self.recording = self.recording.map(|r| r.min(self.journal.len()));
|
||||
}
|
||||
|
||||
pub fn commands(&self) -> Vec<CommandInfo> {
|
||||
command_specs().iter().map(|c| c.info(self)).collect()
|
||||
}
|
||||
@@ -598,7 +669,25 @@ impl Session {
|
||||
if let Err(why) = (spec.enabled)(self) {
|
||||
return Err(EngineError::Disabled(id.to_string(), why));
|
||||
}
|
||||
// A debugger whose undo group something else ended locks nothing any more (`macro.debug`
|
||||
// itself says that the run had ended).
|
||||
if id != "macro.debug" {
|
||||
macros::debug::drop_if_ended(self);
|
||||
}
|
||||
let query = !spec.journal && !spec.undoable;
|
||||
// While a macro is being debugged its undo group is open, and its workbook is locked:
|
||||
// between steps only the debugger, queries and commands that look around run there
|
||||
// (`macros::debug::LOOKS_AROUND`). Anything else waits, undoable or not (saving, view
|
||||
// modes, stopping Record Actions, which saves a script), also when another command runs
|
||||
// it. Only the run's own steps go ahead. Other workbooks stay editable.
|
||||
let locked = self.active().is_some_and(|d| self.macro_debug.as_ref().is_some_and(|m| m.locks(d.uid) && !m.stepping()));
|
||||
if locked && !macros::debug::runs_while_locked(id, query) {
|
||||
return Err(EngineError::Disabled(id.to_string(), macros::debug::BUSY.into()));
|
||||
}
|
||||
let before = self.active().map(|d| (d.uid, d.wb.clone(), d.selection.clone()));
|
||||
// A dry run inside this command that a panic cuts short leaves journaling paused and its
|
||||
// touch log open; the panic branch puts both back.
|
||||
let paused_before = self.journal_paused;
|
||||
let params = if params.is_null() { Json::Object(Default::default()) } else { params };
|
||||
let (first_group, group_epoch) = (self.next_group, self.group_epoch);
|
||||
// Whether a group was open on the document before the command, and how long its history was.
|
||||
@@ -608,7 +697,6 @@ impl Session {
|
||||
// because a nested `edit.undo` removes entries. A query runs nothing that changes the
|
||||
// history (`every_query_leaves_the_history_alone` runs each one), so it needs no
|
||||
// copy; front ends run queries all the time.
|
||||
let query = !spec.journal && !spec.undoable;
|
||||
let history_before = before.as_ref().filter(|_| !query).and_then(|(uid, ..)| {
|
||||
#[cfg(test)]
|
||||
tests::HISTORY_COPIES.with(|n| n.set(n.get().saturating_add(1)));
|
||||
@@ -669,7 +757,16 @@ impl Session {
|
||||
}
|
||||
d.calc.rebuild(&d.wb);
|
||||
}
|
||||
if self.journal_paused != paused_before {
|
||||
self.journal_paused = paused_before;
|
||||
for d in self.docs.iter_mut() {
|
||||
d.touched = None;
|
||||
}
|
||||
}
|
||||
if self.depth == 0 {
|
||||
// Only the `macro.debug` call itself can lose the run's place: a command nested
|
||||
// in a step that panics is an error of that step, which the run handles.
|
||||
macros::debug::recover(self);
|
||||
self.trim_history();
|
||||
self.group_step = None;
|
||||
}
|
||||
@@ -687,13 +784,34 @@ impl Session {
|
||||
// finds no recorder. A menu-style call that only opened a dialog (`file.open` with no
|
||||
// file, `home.formatCells` with no style) is not recorded either: the dialog's own
|
||||
// call is, so playing the recording back runs the command once, with its parameters.
|
||||
// A call that opened a dialog and changed the workbook too (a script with an edit before
|
||||
// a menu-style step) is recorded: the edit happened, and the dialog's confirming call
|
||||
// is a step of its own.
|
||||
let opened_dialog = self.ui_requests.get(requests_before..).is_some_and(|new| new.iter().any(|q| matches!(q, UiRequest::Dialog(..))));
|
||||
if let Some((doc, sheet, sheet_name, selection)) = place.filter(|_| !opened_dialog)
|
||||
let changed = before.as_ref().is_some_and(|(uid, wb, _)| self.docs.iter().any(|d| d.uid == *uid && !Arc::ptr_eq(&d.wb, wb)));
|
||||
if let Some((doc, sheet, sheet_name, selection)) = place.filter(|_| (!opened_dialog || changed) && !record::NOT_RECORDED.contains(&id))
|
||||
&& let Some(rec) = self.recorder.as_mut()
|
||||
{
|
||||
rec.push(record::RecordedStep { doc, sheet, sheet_name, selection, id: id.to_string(), params: cmd::journal_params(¶ms) });
|
||||
// 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(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 });
|
||||
// A script or macro run is kept with the steps it ran, so editing or deleting the
|
||||
// script or macro before the recording stops does not change what was recorded.
|
||||
if let Some(ran) = self.script_ran.take()
|
||||
&& kept
|
||||
&& matches!(id, "automate.runScript" | "macro.run")
|
||||
{
|
||||
rec.attach_ran(ran);
|
||||
}
|
||||
}
|
||||
if self.journal_paused == 0 {
|
||||
self.push_journal(id, cmd::journal_params(¶ms));
|
||||
recent::note(&mut self.prefs.recent_functions, id, ¶ms);
|
||||
}
|
||||
self.push_journal(id, cmd::journal_params(¶ms));
|
||||
}
|
||||
// Inside an undo group the group owns the step. A command that ended a group open before
|
||||
// it leaves the group its step and makes one of its own for what it changed after the end.
|
||||
@@ -808,6 +926,7 @@ impl Session {
|
||||
wb: d.wb.clone(),
|
||||
selection: d.selection.clone(),
|
||||
redo: d.redo.clone(),
|
||||
recorded: self.recorder.as_ref().map(record::Recorder::mark),
|
||||
};
|
||||
let token = GroupToken { id: g.id, doc: g.doc };
|
||||
self.next_group = self.next_group.saturating_add(1);
|
||||
@@ -904,6 +1023,11 @@ impl Session {
|
||||
d.undo.truncate(g.start);
|
||||
d.redo = g.redo;
|
||||
d.revision += 1;
|
||||
// The steps recorded while the group was open go with their edits (all of them when the
|
||||
// recording started inside the group).
|
||||
if let Some(rec) = self.recorder.as_mut() {
|
||||
rec.cut_back(g.recorded.unwrap_or((0, 0)), g.doc);
|
||||
}
|
||||
// The record of the last group step stays when the document is back exactly where that
|
||||
// step left it (this group began there); otherwise the rollback is where the group ended.
|
||||
let kept = self.group_step.as_ref().is_some_and(|st| st.doc == g.doc && st.len == g.start && Arc::ptr_eq(&st.after, &d.wb));
|
||||
@@ -935,6 +1059,21 @@ impl Session {
|
||||
self.depth
|
||||
}
|
||||
|
||||
/// Runs a command as a command in progress would (one level deeper), for tests of what a
|
||||
/// nested call may do.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn execute_nested(&mut self, id: &str, params: Json) -> Result<Json> {
|
||||
self.depth = self.depth.saturating_add(1);
|
||||
let r = self.execute(id, params);
|
||||
self.depth = self.depth.saturating_sub(1);
|
||||
r
|
||||
}
|
||||
|
||||
/// Whether the token's undo group is still open (nothing has ended or rolled it back).
|
||||
pub fn group_is_open(&self, token: &GroupToken) -> bool {
|
||||
self.groups.iter().any(|g| g.id == token.id)
|
||||
}
|
||||
|
||||
/// Whether an undo group is open on the document with this uid.
|
||||
pub fn in_group(&self, doc: u64) -> bool {
|
||||
self.groups.iter().any(|g| g.doc == doc)
|
||||
|
||||
@@ -0,0 +1,694 @@
|
||||
//! The macro debugger: a run that stops between steps.
|
||||
//!
|
||||
//! `macro.debug` starts a run that pauses before its first step, then steps, continues to the next
|
||||
//! breakpoint, runs to a chosen step, or stops. The run lives on the session between calls (one
|
||||
//! action per call) inside an undo group that spans them; Stop keeps the changes as one undo step
|
||||
//! or rolls them back. While a macro is being debugged its workbook is locked: only the
|
||||
//! debugger, queries and the commands in [`LOOKS_AROUND`] run there, and everything else, saving
|
||||
//! included, is refused ("Finish or stop the macro first"). Other workbooks stay editable. The
|
||||
//! debugger runs on its own, never as a step of a script or macro, and is never recorded.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_model::Workbook;
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::run::{Machine, StepError, rolled_back};
|
||||
use crate::{EngineError, GroupToken, Result, Selection, Session};
|
||||
|
||||
/// The most breakpoints one call sets.
|
||||
pub const MAX_BREAKPOINTS: usize = 10_000;
|
||||
/// The most entries `changed` lists (ranges and sheet notes), as the footprint lists ranges.
|
||||
const MAX_LISTED: usize = 500;
|
||||
|
||||
/// Commands that run on the debugged workbook between steps, besides `macro.debug` and queries.
|
||||
/// They only look around (the selection, the active sheet, Go To, Find, zoom, copying), switch to
|
||||
/// another window, close the workbook (which ends the run) or stop the run (Stop Action). The
|
||||
/// debugger puts the run's active sheet and selection back before its next step runs.
|
||||
pub const LOOKS_AROUND: &[&str] = &[
|
||||
"selection.set",
|
||||
"selection.move",
|
||||
"selection.next",
|
||||
"selection.row",
|
||||
"selection.column",
|
||||
"selection.currentRegion",
|
||||
"edit.selectAll",
|
||||
"sheet.activate",
|
||||
"sheet.next",
|
||||
"sheet.previous",
|
||||
"edit.goTo",
|
||||
"edit.goToSpecial",
|
||||
"edit.find",
|
||||
"edit.copy",
|
||||
"view.zoom",
|
||||
"view.zoom100",
|
||||
"view.zoomToSelection",
|
||||
"window.activate",
|
||||
"file.close",
|
||||
"automate.stopAction",
|
||||
];
|
||||
|
||||
/// Whether command `id` (a query when `query`) runs on a workbook the debugger locks.
|
||||
pub fn runs_while_locked(id: &str, query: bool) -> bool {
|
||||
// `macro.check` is a query, but its dry run runs the macro's steps, which the lock refuses: it
|
||||
// waits too, rather than report the lock as the failure of a step.
|
||||
(query && id != "macro.check") || id == "macro.debug" || LOOKS_AROUND.contains(&id)
|
||||
}
|
||||
|
||||
/// A macro being debugged.
|
||||
pub struct Debugger {
|
||||
pub name: String,
|
||||
pub doc: u64,
|
||||
/// Out of the session only while a step runs.
|
||||
machine: Option<Machine>,
|
||||
token: Option<GroupToken>,
|
||||
/// Breakpoints set for this run (step paths), on top of the steps' own.
|
||||
pub breakpoints: BTreeSet<String>,
|
||||
/// Only `breakpoints` stop the run; the steps' own flags are ignored (an editor that shows
|
||||
/// its own breakpoints sends them all, so clearing one takes effect at once).
|
||||
pub only_listed: bool,
|
||||
/// The workbook when the run started, for "changed so far".
|
||||
start: Arc<Workbook>,
|
||||
/// Every step path in the macro, for checking `runTo` targets and breakpoints.
|
||||
paths: BTreeSet<String>,
|
||||
/// The workbooks the run has touched (by uid). Steps run on the macro's own workbook and the
|
||||
/// policy refuses window and file commands, so this is the macro's workbook unless a command
|
||||
/// reaches another one some other way; edits of these wait until the run ends.
|
||||
touched: BTreeSet<u64>,
|
||||
/// Steps the footprint's dry runs executed before the run (they count towards the run's
|
||||
/// budget, not towards `executed`).
|
||||
base: u64,
|
||||
/// The conflict rule that shifted the data at the start (`shiftDown`, `shiftRight`).
|
||||
shifted: Option<&'static str>,
|
||||
/// Where the run left its workbook after the last action.
|
||||
view: Option<View>,
|
||||
}
|
||||
|
||||
/// The active sheet and the selections of the debugged workbook where the run left them.
|
||||
#[derive(Clone)]
|
||||
struct View {
|
||||
sheet: usize,
|
||||
selection: Selection,
|
||||
sheet_selections: Vec<Selection>,
|
||||
}
|
||||
|
||||
impl Debugger {
|
||||
/// Whether edits of the workbook with this uid wait for the run to end.
|
||||
pub fn locks(&self, doc: u64) -> bool {
|
||||
self.touched.contains(&doc)
|
||||
}
|
||||
|
||||
/// Whether one of the run's steps is running (its machine is out of the session).
|
||||
pub fn stepping(&self) -> bool {
|
||||
self.machine.is_none()
|
||||
}
|
||||
|
||||
/// The run's own steps so far.
|
||||
fn executed(&self) -> u64 {
|
||||
self.machine.as_ref().map_or(0, |m| m.executed.saturating_sub(self.base))
|
||||
}
|
||||
}
|
||||
|
||||
/// Each open workbook's uid and its current version, to see which ones a step changes.
|
||||
fn versions(s: &Session) -> Vec<(u64, Arc<Workbook>)> {
|
||||
s.docs.iter().map(|d| (d.uid, d.wb.clone())).collect()
|
||||
}
|
||||
|
||||
/// Adds to the run's touched log the workbooks that changed since `before`.
|
||||
fn note_touched(s: &mut Session, before: &[(u64, Arc<Workbook>)]) {
|
||||
let changed: Vec<u64> =
|
||||
s.docs.iter().filter(|d| before.iter().any(|(uid, wb)| *uid == d.uid && !Arc::ptr_eq(wb, &d.wb))).map(|d| d.uid).collect();
|
||||
if let Some(dbg) = s.macro_debug.as_mut() {
|
||||
dbg.touched.extend(changed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remembers where the run left its workbook: the active sheet and the selections.
|
||||
fn remember_view(s: &mut Session) {
|
||||
let Some(uid) = s.macro_debug.as_ref().map(|d| d.doc) else { return };
|
||||
let view = s.docs.iter().find(|d| d.uid == uid).map(|d| View {
|
||||
sheet: d.wb.active_sheet(),
|
||||
selection: d.selection.clone(),
|
||||
sheet_selections: d.sheet_selections.clone(),
|
||||
});
|
||||
if let Some(dbg) = s.macro_debug.as_mut() {
|
||||
dbg.view = view;
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts back the active sheet and the selections where the run left them, so the next step runs
|
||||
/// where `macro.run` would run it, however the person looked around meanwhile.
|
||||
fn put_back_view(s: &mut Session) {
|
||||
let Some((uid, Some(view))) = s.macro_debug.as_ref().map(|d| (d.doc, d.view.clone())) else { return };
|
||||
let Some(d) = s.docs.iter_mut().find(|d| d.uid == uid) else { return };
|
||||
if view.sheet < d.wb.sheets.len() {
|
||||
// The active sheet is view state: setting it doesn't make the workbook dirty.
|
||||
d.wb.set_active_sheet(view.sheet);
|
||||
}
|
||||
d.selection = view.selection;
|
||||
d.sheet_selections = view.sheet_selections;
|
||||
}
|
||||
|
||||
/// The message for edits refused while a macro is being debugged.
|
||||
pub const BUSY: &str = "Finish or stop the macro first";
|
||||
|
||||
/// Ends a debugger whose undo group is no longer open (something other than the debugger ended
|
||||
/// or rolled it back): its workbook is not locked any more. Returns the macro's name when it did.
|
||||
pub(crate) fn drop_if_ended(s: &mut Session) -> Option<String> {
|
||||
let d = s.macro_debug.as_ref()?;
|
||||
if d.stepping() || d.token.as_ref().is_some_and(|t| s.group_is_open(t)) {
|
||||
return None;
|
||||
}
|
||||
s.macro_debug.take().map(|d| d.name)
|
||||
}
|
||||
|
||||
/// After Stop Action rolled back the undo groups of the workbook with uid `doc`: ends the
|
||||
/// debugger if its run was there. Returns the macro's name and the files the run wrote, which stay.
|
||||
pub(crate) fn end_stopped(s: &mut Session, doc: u64) -> Option<(String, Vec<String>)> {
|
||||
if !s.macro_debug.as_ref().is_some_and(|d| d.doc == doc && !d.stepping()) {
|
||||
return None;
|
||||
}
|
||||
let d = s.macro_debug.take()?;
|
||||
Some((d.name, d.machine.map(|m| m.wrote_files).unwrap_or_default()))
|
||||
}
|
||||
|
||||
/// Why an action finds the run already ended: its undo group was closed outside the debugger.
|
||||
fn already_ended(name: &str) -> String {
|
||||
format!("Macro '{name}' was no longer being debugged: its undo group had been closed outside the debugger, so nothing was kept or rolled back.")
|
||||
}
|
||||
|
||||
enum Mode {
|
||||
Step,
|
||||
Continue,
|
||||
RunTo(String),
|
||||
}
|
||||
|
||||
const ACTIONS: [&str; 6] = ["start", "step", "continue", "runTo", "stop", "state"];
|
||||
|
||||
/// `macro.debug {name?, inputs?, action: start|step|continue|runTo|stop|state, step?, keep?,
|
||||
/// breakpoints?, breakpointsOnly?}`.
|
||||
pub fn debug(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
// A step of a script or macro that started the debugger would leave it behind when the
|
||||
// script fails and rolls back its groups, the debugger's among them.
|
||||
if s.depth() > 1 {
|
||||
return Err(EngineError::Disabled("macro.debug".into(), "The macro debugger runs on its own, not as a step of a script or macro.".into()));
|
||||
}
|
||||
let ended = drop_if_ended(s);
|
||||
let action = match p.get("action") {
|
||||
None | Some(Json::Null) if s.macro_debug.is_some() || ended.is_some() => "state".to_string(),
|
||||
None | Some(Json::Null) => "start".to_string(),
|
||||
Some(Json::String(a)) => a.clone(),
|
||||
// `{"action": {"runTo": {"step": "3"}}}`
|
||||
Some(Json::Object(o)) if o.len() == 1 => o.keys().next().cloned().unwrap_or_default(),
|
||||
Some(_) => return Err(bad("`action` must be start, step, continue, runTo, stop or state")),
|
||||
};
|
||||
if !ACTIONS.contains(&action.as_str()) {
|
||||
return Err(bad(format!("action must be start, step, continue, runTo, stop or state, not '{action}'")));
|
||||
}
|
||||
if let Some(name) = ended
|
||||
&& action != "start"
|
||||
{
|
||||
return Err(EngineError::Other(already_ended(&name)));
|
||||
}
|
||||
let only = bool_param(p, "breakpointsOnly")?;
|
||||
if action == "start" {
|
||||
return start(s, p, only.unwrap_or(false));
|
||||
}
|
||||
let keep = bool_param(p, "keep")?;
|
||||
let d = s.macro_debug.as_ref().ok_or_else(not_debugging)?;
|
||||
let target = if action == "runTo" {
|
||||
let target = p
|
||||
.get("step")
|
||||
.or_else(|| p.pointer("/action/runTo/step"))
|
||||
.and_then(path_of)
|
||||
.ok_or_else(|| bad("`runTo` needs `step`: the path of the step to stop at"))?;
|
||||
if !d.paths.contains(&target) {
|
||||
return Err(bad(format!("macro '{}' has no step {target}", d.name)));
|
||||
}
|
||||
Some(target)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let breakpoints = p.get("breakpoints").filter(|v| !v.is_null()).map(|v| paths_param(v, &d.paths, &d.name)).transpose()?;
|
||||
if let Some(d) = s.macro_debug.as_mut() {
|
||||
if let Some(bps) = breakpoints {
|
||||
d.breakpoints = bps;
|
||||
}
|
||||
if let Some(only) = only {
|
||||
d.only_listed = only;
|
||||
}
|
||||
}
|
||||
match (action.as_str(), target) {
|
||||
("step", _) => advance(s, Mode::Step),
|
||||
("continue", _) => advance(s, Mode::Continue),
|
||||
("runTo", Some(target)) => advance(s, Mode::RunTo(target)),
|
||||
("stop", _) => stop(s, keep.unwrap_or(false)),
|
||||
_ => state(s),
|
||||
}
|
||||
}
|
||||
|
||||
fn bad(msg: impl Into<String>) -> EngineError {
|
||||
crate::cmd::bad("macro.debug", msg)
|
||||
}
|
||||
|
||||
fn not_debugging() -> EngineError {
|
||||
EngineError::Other("No macro is being debugged. Start one with action: start.".into())
|
||||
}
|
||||
|
||||
/// `true`, `false`, or not given (`null` counts as not given); anything else is refused.
|
||||
fn bool_param(p: &Json, key: &str) -> Result<Option<bool>> {
|
||||
match p.get(key) {
|
||||
None | Some(Json::Null) => Ok(None),
|
||||
Some(Json::Bool(b)) => Ok(Some(*b)),
|
||||
Some(_) => Err(bad(format!("`{key}` must be true or false"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// A step path from JSON: `"2.1"` or `3`.
|
||||
fn path_of(v: &Json) -> Option<String> {
|
||||
match v {
|
||||
Json::String(t) if !t.trim().is_empty() => Some(t.trim().to_string()),
|
||||
Json::Number(n) => n.as_u64().map(|n| n.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `breakpoints` list: step paths of macro `name` (one of `paths`), at most
|
||||
/// [`MAX_BREAKPOINTS`].
|
||||
fn paths_param(v: &Json, paths: &BTreeSet<String>, name: &str) -> Result<BTreeSet<String>> {
|
||||
let list = v.as_array().ok_or_else(|| bad("`breakpoints` must be a list of step paths"))?;
|
||||
if list.len() > MAX_BREAKPOINTS {
|
||||
return Err(bad(format!("`breakpoints` lists {} steps; at most {MAX_BREAKPOINTS} can be set", list.len())));
|
||||
}
|
||||
let mut out = BTreeSet::new();
|
||||
for x in list {
|
||||
let path = path_of(x).ok_or_else(|| bad("a breakpoint is a step path such as \"3\" or \"2.1\""))?;
|
||||
if !paths.contains(&path) {
|
||||
return Err(bad(format!("macro '{name}' has no step {path} for a breakpoint")));
|
||||
}
|
||||
out.insert(path);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn start(s: &mut Session, p: &Json, only_listed: bool) -> Result<Json> {
|
||||
if let Some(d) = &s.macro_debug {
|
||||
return Err(EngineError::Other(format!("Macro '{}' is being debugged. Stop it first.", d.name)));
|
||||
}
|
||||
let name = p.get("name").and_then(Json::as_str).filter(|n| !n.trim().is_empty()).ok_or_else(|| bad("missing `name`"))?;
|
||||
let b = super::run::begin(s, name, p)?;
|
||||
let paths = all_paths(&b.mac.steps, "");
|
||||
let breakpoints = match p.get("breakpoints").filter(|v| !v.is_null()) {
|
||||
Some(v) => paths_param(v, &paths, &b.mac.name)?,
|
||||
None => BTreeSet::new(),
|
||||
};
|
||||
let start_wb = s.doc()?.wb.clone();
|
||||
let token = s.group_begin(&format!("Run Macro: {}", b.mac.name))?;
|
||||
// The run's machine: unless the rule is overwrite, it checks each step for writes over data.
|
||||
let (mut machine, shifted) = match super::run::make_way(s, &b) {
|
||||
Ok(made) => made,
|
||||
Err(e) => {
|
||||
let _ = s.group_rollback(token);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
// Each action has the time limit to itself; the dry runs just had theirs.
|
||||
machine.restart_clock();
|
||||
let base = machine.executed;
|
||||
s.macro_debug = Some(Debugger {
|
||||
name: b.mac.name.clone(),
|
||||
doc: b.prepared.doc,
|
||||
machine: Some(machine),
|
||||
token: Some(token),
|
||||
breakpoints,
|
||||
only_listed,
|
||||
start: start_wb,
|
||||
paths,
|
||||
touched: BTreeSet::from([b.prepared.doc]),
|
||||
base,
|
||||
shifted: shifted.then(|| b.rule.name()),
|
||||
view: None,
|
||||
});
|
||||
if s.macro_debug.as_ref().and_then(|d| d.machine.as_ref()).is_some_and(Machine::is_done) {
|
||||
return finish(s, None);
|
||||
}
|
||||
remember_view(s);
|
||||
state(s)
|
||||
}
|
||||
|
||||
fn all_paths(steps: &[gridcraft_model::Step], prefix: &str) -> BTreeSet<String> {
|
||||
let mut out = BTreeSet::new();
|
||||
for (i, st) in steps.iter().enumerate() {
|
||||
let path = if prefix.is_empty() { (i + 1).to_string() } else { format!("{prefix}.{}", i + 1) };
|
||||
match &st.kind {
|
||||
gridcraft_model::StepKind::Repeat { body, .. } => out.extend(all_paths(body, &path)),
|
||||
gridcraft_model::StepKind::If { then, otherwise, .. } => {
|
||||
out.extend(all_paths(then, &format!("{path}.then")));
|
||||
out.extend(all_paths(otherwise, &format!("{path}.else")));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
out.insert(path);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Runs one step (`Step`), or steps until a breakpoint, the target step or the end.
|
||||
fn advance(s: &mut Session, mode: Mode) -> Result<Json> {
|
||||
put_back_view(s);
|
||||
let d = s.macro_debug.as_mut().ok_or_else(not_debugging)?;
|
||||
let (name, base) = (d.name.clone(), d.base);
|
||||
let breakpoints = d.breakpoints.clone();
|
||||
let only_listed = d.only_listed;
|
||||
let mut machine = d.machine.take().ok_or_else(not_debugging)?;
|
||||
machine.restart_clock();
|
||||
let before = versions(s);
|
||||
let mut first = true;
|
||||
let mut hit = false;
|
||||
let mut outcome = Ok(());
|
||||
while !machine.is_done() {
|
||||
if !first {
|
||||
let Some((path, st)) = machine.current() else { break };
|
||||
if matches!(mode, Mode::Step) {
|
||||
break;
|
||||
}
|
||||
if (st.breakpoint && !only_listed) || breakpoints.contains(&path) {
|
||||
hit = true;
|
||||
break;
|
||||
}
|
||||
if let Mode::RunTo(target) = &mode
|
||||
&& *target == path
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
first = false;
|
||||
outcome = machine.step(s);
|
||||
if outcome.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let done = machine.is_done();
|
||||
match s.macro_debug.as_mut() {
|
||||
Some(d) => d.machine = Some(machine),
|
||||
// The macro.debug call panicked and `recover` has already rolled the run back.
|
||||
None => {
|
||||
return match outcome {
|
||||
Err(e) => Ok(failed(&name, machine.executed.saturating_sub(base), &e)),
|
||||
Ok(()) => Err(not_debugging()),
|
||||
};
|
||||
}
|
||||
}
|
||||
note_touched(s, &before);
|
||||
if let Err(e) = outcome {
|
||||
return finish(s, Some(e));
|
||||
}
|
||||
if done {
|
||||
return finish(s, None);
|
||||
}
|
||||
remember_view(s);
|
||||
let mut out = state(s)?;
|
||||
out["atBreakpoint"] = json!(hit);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// What every answer about the run's end or its state adds: the data shift at the start and the
|
||||
/// files the run wrote (they stay, whatever happens to the workbook).
|
||||
fn add_notes(out: &mut Json, d: &Debugger, wrote_files: &[String]) {
|
||||
if let Some(rule) = d.shifted {
|
||||
out["shifted"] = json!(rule);
|
||||
}
|
||||
if !wrote_files.is_empty() {
|
||||
out["wroteFiles"] = json!(wrote_files);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends the run after its last step (kept as one undo step, unless a Stop step asked for a
|
||||
/// rollback) or a failed step (rolled back).
|
||||
fn finish(s: &mut Session, error: Option<StepError>) -> Result<Json> {
|
||||
let Some(mut d) = s.macro_debug.take() else { return Err(not_debugging()) };
|
||||
let (changed, cells) = changed(s, &d);
|
||||
let executed = d.executed();
|
||||
let machine = d.machine.take();
|
||||
let stopped = machine.as_ref().and_then(|m| m.stopped.clone());
|
||||
let wrote = machine.map(|m| m.wrote_files).unwrap_or_default();
|
||||
let rollback = error.is_some() || matches!(stopped, Some((_, true)));
|
||||
let ended = match d.token.take() {
|
||||
Some(token) if rollback => s.group_rollback(token).is_ok(),
|
||||
Some(token) => s.group_end(token).is_ok(),
|
||||
None => false,
|
||||
};
|
||||
if !ended {
|
||||
let step = error.map(|e| format!("{} ", e.describe())).unwrap_or_default();
|
||||
return Err(EngineError::Other(format!("{step}{}", already_ended(&d.name))));
|
||||
}
|
||||
if let Some(e) = &error {
|
||||
return Ok(failed(&d.name, executed, e));
|
||||
}
|
||||
let mut out = json!({"macro": d.name, "executed": executed});
|
||||
match &stopped {
|
||||
Some((msg, rolled)) => {
|
||||
out["state"] = json!("stopped");
|
||||
out["stopped"] = json!(msg);
|
||||
out["kept"] = json!(!rolled);
|
||||
if *rolled {
|
||||
out["message"] = json!(rolled_back(&wrote));
|
||||
}
|
||||
}
|
||||
None => out["state"] = json!("finished"),
|
||||
}
|
||||
if !rollback {
|
||||
out["changed"] = json!(changed);
|
||||
out["changedCells"] = json!(cells);
|
||||
}
|
||||
add_notes(&mut out, &d, &wrote);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The result of a run that a failed step rolled back.
|
||||
fn failed(name: &str, executed: u64, e: &StepError) -> Json {
|
||||
let mut out = json!({
|
||||
"macro": name,
|
||||
"executed": executed,
|
||||
"state": "failed",
|
||||
"error": serde_json::to_value(e).unwrap_or(Json::Null),
|
||||
"message": format!("{} {}", e.describe(), rolled_back(&e.wrote_files)),
|
||||
});
|
||||
if !e.wrote_files.is_empty() {
|
||||
out["wroteFiles"] = json!(e.wrote_files);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Stops the run: keeps what it did as one undo step, or rolls it back.
|
||||
fn stop(s: &mut Session, keep: bool) -> Result<Json> {
|
||||
let Some(mut d) = s.macro_debug.take() else { return Err(not_debugging()) };
|
||||
let (changed, cells) = changed(s, &d);
|
||||
let ended = match d.token.take() {
|
||||
Some(token) if keep => s.group_end(token).is_ok(),
|
||||
Some(token) => s.group_rollback(token).is_ok(),
|
||||
None => false,
|
||||
};
|
||||
if !ended {
|
||||
return Err(EngineError::Other(already_ended(&d.name)));
|
||||
}
|
||||
let wrote = d.machine.as_ref().map(|m| m.wrote_files.clone()).unwrap_or_default();
|
||||
let mut out = json!({"state": "stopped", "macro": d.name, "kept": keep, "executed": d.executed()});
|
||||
if keep {
|
||||
out["changed"] = json!(changed);
|
||||
out["changedCells"] = json!(cells);
|
||||
} else {
|
||||
out["message"] = json!(rolled_back(&wrote));
|
||||
}
|
||||
add_notes(&mut out, &d, &wrote);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Where the run is: the next step with its parameters as they would run, the step that just
|
||||
/// ran, and what the run has changed in its workbook so far.
|
||||
fn state(s: &mut Session) -> Result<Json> {
|
||||
let d = s.macro_debug.as_ref().ok_or_else(not_debugging)?;
|
||||
let machine = d.machine.as_ref().ok_or_else(not_debugging)?;
|
||||
let wb = s.docs.iter().find(|x| x.uid == d.doc).map(|x| x.wb.clone()).ok_or(EngineError::NoDocument)?;
|
||||
let (changed, cells) = changed(s, d);
|
||||
let mut out = json!({
|
||||
"state": "paused",
|
||||
"macro": d.name,
|
||||
"current": machine.current_json(&wb),
|
||||
"lastParams": machine.last_params,
|
||||
"executed": d.executed(),
|
||||
"breakpoints": d.breakpoints,
|
||||
"changed": changed,
|
||||
"changedCells": cells,
|
||||
});
|
||||
add_notes(&mut out, d, &machine.wrote_files);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// What the run changed in its workbook since it started: cells as `Sheet!A1:B2` ranges and the
|
||||
/// sheets it added, deleted or renamed (at most [`MAX_LISTED`] entries), and how many cells
|
||||
/// changed on the sheets the workbook had at the start.
|
||||
fn changed(s: &Session, d: &Debugger) -> (Vec<String>, usize) {
|
||||
let Some(now) = s.docs.iter().find(|x| x.uid == d.doc).map(|x| x.wb.clone()) else { return (vec![], 0) };
|
||||
let (pairs, added) = super::run::pair_sheets(&d.start, &now);
|
||||
let mut out = Vec::new();
|
||||
let mut count = 0usize;
|
||||
for (bi, ai) in pairs {
|
||||
let Some(before) = d.start.sheets.get(bi) else { continue };
|
||||
let Some(after) = ai.and_then(|a| now.sheets.get(a)) else {
|
||||
out.push(format!("{} (deleted sheet)", before.name));
|
||||
continue;
|
||||
};
|
||||
if before.name != after.name {
|
||||
out.push(format!("{} (renamed from {})", after.name, before.name));
|
||||
}
|
||||
if Arc::ptr_eq(before, after) {
|
||||
continue;
|
||||
}
|
||||
let mut cells = before.cells.diff(&after.cells);
|
||||
count = count.saturating_add(cells.len());
|
||||
if out.len() < MAX_LISTED {
|
||||
let q = super::resolve::quote_sheet(&after.name);
|
||||
out.extend(super::run::ranges(&mut cells).into_iter().map(|r| format!("{q}!{r}")));
|
||||
}
|
||||
}
|
||||
out.extend(added.into_iter().filter_map(|a| now.sheets.get(a)).map(|sh| format!("{} (new sheet)", sh.name)));
|
||||
out.truncate(MAX_LISTED);
|
||||
(out, count)
|
||||
}
|
||||
|
||||
/// After the `macro.debug` call itself panicked: if the run lost its place, roll it back so its
|
||||
/// undo group doesn't stay open.
|
||||
pub(crate) fn recover(s: &mut Session) {
|
||||
if s.macro_debug.as_ref().is_some_and(Debugger::stepping)
|
||||
&& let Some(mut d) = s.macro_debug.take()
|
||||
&& let Some(token) = d.token.take()
|
||||
{
|
||||
let _ = s.group_rollback(token);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn a_run_that_lost_its_place_is_rolled_back() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let m = crate::macros::parse_macro(&json!({"name": "M", "output": {"onConflict": "overwrite"}, "steps": [
|
||||
{"command": "cell.set", "params": {"cell": "A1", "input": "x"}},
|
||||
{"command": "cell.set", "params": {"cell": "A2", "input": "y"}}
|
||||
]}))
|
||||
.unwrap();
|
||||
let d = s.doc_mut().unwrap();
|
||||
let mut wb = (*d.wb).clone();
|
||||
wb.macros.push(m);
|
||||
d.wb = std::sync::Arc::new(wb);
|
||||
let start = s.doc().unwrap().wb.clone();
|
||||
s.execute("macro.debug", json!({"name": "M"})).unwrap();
|
||||
s.execute("macro.debug", json!({"action": "step"})).unwrap();
|
||||
// As if a step had panicked with the machine out of the session.
|
||||
s.macro_debug.as_mut().unwrap().machine = None;
|
||||
super::recover(&mut s);
|
||||
assert!(s.macro_debug.is_none());
|
||||
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &start));
|
||||
assert!(!s.in_group(s.doc().unwrap().uid));
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "free"})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_step_that_panics_reports_the_step_failure() {
|
||||
crate::register_test_command("test.debugStepPanics", |_, _| panic!("boom"));
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let m = crate::macros::parse_macro(&json!({"name": "M", "output": {"onConflict": "overwrite"}, "steps": [
|
||||
{"command": "cell.set", "params": {"cell": "A1", "input": "x"}},
|
||||
{"command": "test.debugStepPanics", "params": {}}
|
||||
]}))
|
||||
.unwrap();
|
||||
let d = s.doc_mut().unwrap();
|
||||
let mut wb = (*d.wb).clone();
|
||||
wb.macros.push(m);
|
||||
d.wb = std::sync::Arc::new(wb);
|
||||
let start = s.doc().unwrap().wb.clone();
|
||||
s.execute("macro.debug", json!({"name": "M"})).unwrap();
|
||||
s.execute("macro.debug", json!({"action": "step"})).unwrap();
|
||||
let r = s.execute("macro.debug", json!({"action": "step"})).unwrap();
|
||||
assert_eq!(r["state"], "failed", "{r}");
|
||||
assert_eq!(r["macro"], "M");
|
||||
assert_eq!(r["error"]["step"], "2");
|
||||
assert!(r["message"].as_str().unwrap().contains("boom"), "{r}");
|
||||
assert!(s.macro_debug.is_none());
|
||||
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &start));
|
||||
assert!(!s.in_group(s.doc().unwrap().uid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_workbook_in_the_touched_log_waits_until_the_run_ends() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let m = crate::macros::parse_macro(&json!({"name": "M", "output": {"onConflict": "overwrite"}, "steps": [
|
||||
{"command": "cell.set", "params": {"cell": "A1", "input": "x"}},
|
||||
{"command": "cell.set", "params": {"cell": "A2", "input": "y"}}
|
||||
]}))
|
||||
.unwrap();
|
||||
let d = s.doc_mut().unwrap();
|
||||
let mut wb = (*d.wb).clone();
|
||||
wb.macros.push(m);
|
||||
d.wb = std::sync::Arc::new(wb);
|
||||
s.new_workbook();
|
||||
let other = s.doc().unwrap().uid;
|
||||
s.set_active(0);
|
||||
s.execute("macro.debug", json!({"name": "M"})).unwrap();
|
||||
assert!(!s.macro_debug.as_ref().unwrap().locks(other));
|
||||
// As if a step had changed the other workbook too.
|
||||
let before = super::versions(&s);
|
||||
let d = s.docs.iter_mut().find(|d| d.uid == other).unwrap();
|
||||
let mut wb = (*d.wb).clone();
|
||||
wb.props.title = "changed by the macro".into();
|
||||
d.wb = std::sync::Arc::new(wb);
|
||||
super::note_touched(&mut s, &before);
|
||||
assert!(s.macro_debug.as_ref().unwrap().locks(other));
|
||||
s.set_active(1);
|
||||
assert!(s.execute("cell.set", json!({"cell": "B1", "input": "no"})).is_err());
|
||||
s.execute("macro.debug", json!({"action": "stop", "keep": true})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "free"})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_run_by_another_command_wait_too() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let m = crate::macros::parse_macro(&json!({"name": "M", "output": {"onConflict": "overwrite"}, "steps": [
|
||||
{"command": "cell.set", "params": {"cell": "A1", "input": "x"}},
|
||||
{"command": "cell.set", "params": {"cell": "A2", "input": "y"}}
|
||||
]}))
|
||||
.unwrap();
|
||||
let d = s.doc_mut().unwrap();
|
||||
let mut wb = (*d.wb).clone();
|
||||
wb.macros.push(m);
|
||||
d.wb = std::sync::Arc::new(wb);
|
||||
s.execute("automate.recordActions", json!({})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C1", "input": "1"})).unwrap();
|
||||
s.execute("macro.debug", json!({"name": "M"})).unwrap();
|
||||
s.execute("macro.debug", json!({"action": "step"})).unwrap();
|
||||
// Record Actions changes nothing itself; stopping it saves the script with a nested
|
||||
// command, which would land in the run's undo group.
|
||||
let e = s.execute("automate.recordActions", json!({"stop": true, "name": "S"})).unwrap_err().to_string();
|
||||
assert!(e.contains(super::BUSY), "{e}");
|
||||
assert!(s.execute("automate.allScripts", json!({})).unwrap().as_array().unwrap().is_empty());
|
||||
// The run's own steps still go ahead.
|
||||
let r = s.execute("macro.debug", json!({"action": "step"})).unwrap();
|
||||
assert_eq!(r["executed"], 2, "{r}");
|
||||
assert!(s.macro_debug.is_none());
|
||||
// The recording was kept: stopping now saves it.
|
||||
let saved = s.execute("automate.recordActions", json!({"stop": true, "name": "S"})).unwrap();
|
||||
assert_eq!(saved["saved"], "S", "{saved}");
|
||||
let scripts = s.execute("automate.allScripts", json!({})).unwrap();
|
||||
assert_eq!(scripts[0]["name"], "S", "{scripts}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
//! GridCraft macros: typed, declarative command lists, not VBA. The model is plain data in
|
||||
//! `gridcraft_model::Macro`; this module reads and writes it and keeps it within its limits.
|
||||
//!
|
||||
//! Storage: the JSON format keeps `Workbook::macros` as a field. In xlsx each macro is one item of
|
||||
//! GridCraft's hidden workbook data ([`crate::cmd::stash`]), `_gc_macro_<n>_<k>`, written when the
|
||||
//! file is saved and taken back out of the names when it is opened, so the names never linger in
|
||||
//! the open document. Scripts made with Automate › New Script or Record Actions are a separate,
|
||||
//! older feature with their own storage; macros neither read nor change it. A recording keeps the
|
||||
//! steps a script run ran while it recorded, as the run reported them.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use gridcraft_model::{DefinedName, Macro, Step, StepKind, Workbook};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use crate::cmd::stash;
|
||||
|
||||
pub mod debug;
|
||||
pub mod policy;
|
||||
pub mod recorder;
|
||||
pub mod resolve;
|
||||
pub mod run;
|
||||
|
||||
/// The hidden-data key prefix of a stored macro (`macro_<n>`).
|
||||
const MACRO_KEY: &str = "macro_";
|
||||
|
||||
/// Reads a macro from JSON, as `macro.save` and files give it. A command step may also use the
|
||||
/// short form `{"command": "cell.set", "params": {…}}`.
|
||||
pub fn parse_macro(v: &Json) -> Result<Macro, String> {
|
||||
// A newer format is refused before its steps are read, so the message says why.
|
||||
if let Some(found) = v.get("v").and_then(Json::as_u64) {
|
||||
gridcraft_model::check_version(found)?;
|
||||
}
|
||||
let mut v = v.clone();
|
||||
if let Some(steps) = v.get_mut("steps") {
|
||||
normalize_steps(steps, 1)?;
|
||||
}
|
||||
if v.get("v").is_none()
|
||||
&& let Some(o) = v.as_object_mut()
|
||||
{
|
||||
o.insert("v".into(), json!(gridcraft_model::MACRO_VERSION));
|
||||
}
|
||||
let mut m: Macro = serde_json::from_value(v).map_err(|e| format!("This isn't a valid macro: {e}"))?;
|
||||
m.name = m.name.trim().to_string();
|
||||
check(&m)?;
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// Every check a macro must pass before it is kept in a workbook: the model's limits
|
||||
/// ([`Macro::check_limits`]) and the size an xlsx file can hold. Every path that adds a macro,
|
||||
/// from JSON, from a file or built in code, goes through this one check.
|
||||
pub fn check(m: &Macro) -> Result<(), String> {
|
||||
m.check_limits()?;
|
||||
check_size(m)
|
||||
}
|
||||
|
||||
/// Refuses a macro too large to keep in an xlsx file ([`stash::MAX_ITEM_CHARS`] characters of
|
||||
/// JSON). Every command that adds or changes a macro calls [`check`], so the size of a macro kept
|
||||
/// in the workbook doesn't make a save fail. (A clashing visible name doesn't either:
|
||||
/// [`with_names`] stores the macro under another index.)
|
||||
fn check_size(m: &Macro) -> Result<(), String> {
|
||||
if macro_json(m).to_string().chars().count() > stash::MAX_ITEM_CHARS {
|
||||
return Err(format!("This macro is too large to keep in the workbook ({} characters at most).", stash::MAX_ITEM_CHARS));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Leaves out the last top-level steps of `m` until it fits the size [`check_size`] allows, and
|
||||
/// returns how many steps it left out (a recording cut to fit, as at the step limit). Each step's
|
||||
/// JSON is measured once.
|
||||
pub fn fit_size(m: &mut Macro) -> usize {
|
||||
let chars = |v: &Json| v.to_string().chars().count();
|
||||
let steps = std::mem::take(&mut m.steps);
|
||||
// The macro without steps holds `"steps":[]`; each step adds its JSON, and a comma after the
|
||||
// first.
|
||||
let mut size = chars(¯o_json(m));
|
||||
let mut kept = 0usize;
|
||||
for (i, st) in steps.iter().enumerate() {
|
||||
let add = chars(&serde_json::to_value(st).unwrap_or(Json::Null)).saturating_add(usize::from(i > 0));
|
||||
if size.saturating_add(add) > stash::MAX_ITEM_CHARS {
|
||||
break;
|
||||
}
|
||||
size = size.saturating_add(add);
|
||||
kept = i.saturating_add(1);
|
||||
}
|
||||
let cut = steps.len().saturating_sub(kept);
|
||||
m.steps = steps;
|
||||
m.steps.truncate(kept);
|
||||
cut
|
||||
}
|
||||
|
||||
/// How many macros the workbook holds: the readable ones and the ones kept in a newer format
|
||||
/// (`Workbook::macros_unread`), which a save writes back and the next open counts too.
|
||||
pub fn count(wb: &Workbook) -> usize {
|
||||
wb.macros.len().saturating_add(wb.macros_unread.len())
|
||||
}
|
||||
|
||||
/// Refuses one more macro in a workbook that holds [`gridcraft_model::MAX_MACROS`] already,
|
||||
/// counting the kept ones, so a save never holds more than the next open keeps.
|
||||
pub fn check_room(wb: &Workbook) -> Result<(), String> {
|
||||
let max = gridcraft_model::MAX_MACROS;
|
||||
if count(wb) < max {
|
||||
return Ok(());
|
||||
}
|
||||
Err(match wb.macros_unread.len() {
|
||||
0 => format!("A workbook can hold at most {max} macros."),
|
||||
kept => format!(
|
||||
"A workbook can hold at most {max} macros, and {kept} of the {} in this one {} made by a newer version of GridCraft and kept unchanged.",
|
||||
count(wb),
|
||||
if kept == 1 { "was" } else { "were" }
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
/// The name of the macro kept in a newer format called `name` (compared as [`find`] compares),
|
||||
/// as the file held it. Its name is taken: a new or renamed macro with it would clash when the
|
||||
/// newer version reads the file.
|
||||
pub fn kept_name(wb: &Workbook, name: &str) -> Option<String> {
|
||||
let name = name.trim();
|
||||
wb.macros_unread
|
||||
.iter()
|
||||
.filter_map(|v| v.get("name").and_then(Json::as_str))
|
||||
.map(str::trim)
|
||||
.find(|k| k.eq_ignore_ascii_case(name))
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// Rewrites the short step form into the tagged one, in place, refusing nesting past the limit
|
||||
/// before the steps are deserialized.
|
||||
fn normalize_steps(steps: &mut Json, level: usize) -> Result<(), String> {
|
||||
let Some(list) = steps.as_array_mut() else { return Err("`steps` must be a list.".into()) };
|
||||
for st in list {
|
||||
let Some(o) = st.as_object_mut() else { return Err("Every step must be an object.".into()) };
|
||||
if !o.contains_key("type")
|
||||
&& let Some(id) = o.remove("command")
|
||||
{
|
||||
o.insert("type".into(), json!("command"));
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
// A step with a body is a Repeat or an If: one at the top is nested 1 deep.
|
||||
let nested = ["body", "then", "else"].iter().any(|key| o.contains_key(*key));
|
||||
if nested && level > gridcraft_model::MAX_MACRO_DEPTH {
|
||||
return Err(format!("Repeat and If steps can nest at most {} deep.", gridcraft_model::MAX_MACRO_DEPTH));
|
||||
}
|
||||
for key in ["body", "then", "else"] {
|
||||
if let Some(body) = o.get_mut(key) {
|
||||
normalize_steps(body, level + 1)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A macro as JSON (the shape `macro.get` returns and `macro.save` takes).
|
||||
pub fn macro_json(m: &Macro) -> Json {
|
||||
serde_json::to_value(m).unwrap_or(Json::Null)
|
||||
}
|
||||
|
||||
/// The index of the macro called `name` (case-insensitive).
|
||||
pub fn find(wb: &Workbook, name: &str) -> Option<usize> {
|
||||
let name = name.trim();
|
||||
wb.macros.iter().position(|m| m.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// A macro's content as canonical JSON text (object keys sorted): approval to run a macro holds
|
||||
/// until the macro changes. It is the text itself rather than a hash, so no two different macros
|
||||
/// share it.
|
||||
pub fn fingerprint(m: &Macro) -> String {
|
||||
macro_json(m).to_string()
|
||||
}
|
||||
|
||||
/// A copy of `wb` for xlsx with each macro in hidden names (`_gc_macro_<n>_<k>`) instead of the
|
||||
/// field, or `None` when there is nothing to change. The macros keep their order; the index `n`
|
||||
/// is this build's choice, so it steps around any a person's own visible name already uses.
|
||||
pub fn with_names(wb: &Workbook) -> crate::Result<Option<Workbook>> {
|
||||
// Only the numbered items are this build's: a hidden `_gc_macro_<x>_<k>` name with some
|
||||
// other key stays, as `load` leaves it.
|
||||
let stored = |n: &DefinedName| macro_item(n).is_some_and(|(_, i)| i.is_some());
|
||||
if wb.macros.is_empty() && wb.macros_unread.is_empty() && !wb.names.iter().any(stored) {
|
||||
return Ok(None);
|
||||
}
|
||||
// A macro that skipped `check` (built in code) fails the save, as the JSON save does, rather
|
||||
// than being written and dropped the next time the file opens.
|
||||
for m in wb.macros.iter() {
|
||||
check(m).map_err(crate::EngineError::Other)?;
|
||||
}
|
||||
let mut out = wb.clone();
|
||||
// One pass drops the items of the last save, and one writes the macros (a file can hold a
|
||||
// thousand macros and many thousands of names).
|
||||
out.names.retain(|n| !stored(n));
|
||||
// A visible workbook-scope name of the storage shape is someone's own, and `set_many` won't
|
||||
// write a hidden one of the same name beside it. Such a name (`_GC_MACRO_1_0`, or one a
|
||||
// person defined after the file opened) blocks its item's key, in any chunk, so the macros
|
||||
// take the free indices from 0 up. The keys compare as the stash reads them, so this skips
|
||||
// exactly what `set_many` would refuse, and no arithmetic depends on a number from the file.
|
||||
let blocked: HashSet<String> = out.names.iter().filter(|n| !n.hidden && n.scope.is_none()).filter_map(|n| stash::item_key(&n.name)).collect();
|
||||
let free = (0..=u64::MAX).map(|i| format!("{MACRO_KEY}{i}")).filter(|key| !blocked.contains(key));
|
||||
// Macros in a newer format follow the readable ones, written back as the file held them.
|
||||
let texts = wb.macros.iter().map(|m| macro_json(m).to_string()).chain(wb.macros_unread.iter().map(Json::to_string));
|
||||
let items: Vec<(String, String)> = texts.zip(free).map(|(text, key)| (key, text)).collect();
|
||||
stash::set_many(&mut out, &items)?;
|
||||
// An empty list of its own, not a copy of the shared one cleared.
|
||||
out.macros = gridcraft_model::Macros::default();
|
||||
out.macros_unread = std::sync::Arc::default();
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
/// The JSON format's bytes for `wb`: the workbook, with the macros in a newer format put back into
|
||||
/// the `macros` list as the file held them.
|
||||
pub fn json_bytes(wb: &Workbook) -> crate::Result<Vec<u8>> {
|
||||
// Every path that adds a macro checks it, so this refuses only one that skipped the check, as
|
||||
// the xlsx save does, instead of writing a macro the next open would drop.
|
||||
for m in &wb.macros {
|
||||
check(m).map_err(crate::EngineError::Other)?;
|
||||
}
|
||||
let bytes = if wb.macros_unread.is_empty() {
|
||||
serde_json::to_vec_pretty(wb)
|
||||
} else {
|
||||
serde_json::to_value(wb).and_then(|mut v| {
|
||||
if let Some(list) = v.as_object_mut().map(|o| o.entry("macros").or_insert_with(|| Json::Array(vec![]))).and_then(Json::as_array_mut) {
|
||||
list.extend(wb.macros_unread.iter().cloned());
|
||||
}
|
||||
serde_json::to_vec_pretty(&v)
|
||||
})
|
||||
};
|
||||
bytes.map_err(|e| crate::EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
/// Whether `v` is a macro in a newer format than this build reads.
|
||||
fn is_newer(v: &Json) -> bool {
|
||||
v.get("v").and_then(Json::as_u64).is_some_and(|found| found > u64::from(gridcraft_model::MACRO_VERSION))
|
||||
}
|
||||
|
||||
/// Keeps a macro in a newer format unread, if it is small enough to save back
|
||||
/// ([`stash::MAX_ITEM_CHARS`] characters of JSON, the size every xlsx save can write).
|
||||
fn keep_unread(unread: &mut Vec<Json>, v: Json, bad: &mut usize) {
|
||||
if v.to_string().chars().count() <= stash::MAX_ITEM_CHARS {
|
||||
unread.push(v);
|
||||
} else {
|
||||
*bad = bad.saturating_add(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// After a file is opened: moves macros out of hidden names into `Workbook::macros`, keeps
|
||||
/// macros in a newer format unread (`Workbook::macros_unread`), and drops macros that are damaged
|
||||
/// or over the limits. Returns a warning for what was kept unread and one for what was dropped.
|
||||
pub fn load(wb: &mut Workbook) -> Vec<String> {
|
||||
let mut warnings = Vec::new();
|
||||
let mut bad = 0usize;
|
||||
let mut unread = Vec::new();
|
||||
// Entries of the JSON format's field, each read on its own so a damaged one drops alone.
|
||||
let read = std::mem::take(&mut wb.macros_read);
|
||||
bad = bad.saturating_add(read.len().saturating_sub(gridcraft_model::MAX_MACROS));
|
||||
for v in read.into_iter().take(gridcraft_model::MAX_MACROS) {
|
||||
if is_newer(&v) {
|
||||
keep_unread(&mut unread, v, &mut bad);
|
||||
continue;
|
||||
}
|
||||
match parse_macro(&v) {
|
||||
Ok(m) => wb.macros.push(m),
|
||||
Err(_) => bad += 1,
|
||||
}
|
||||
}
|
||||
// One pass takes the hidden macro names out of the workbook, grouped by macro in saved order.
|
||||
let mut stored: BTreeMap<(u64, String), Vec<DefinedName>> = BTreeMap::new();
|
||||
let mut kept = Vec::new();
|
||||
for n in std::mem::take(&mut wb.names) {
|
||||
match macro_item(&n) {
|
||||
None => kept.push(n),
|
||||
Some((key, Some(i))) => stored.entry((i, key)).or_default().push(n),
|
||||
// A `macro_` item that isn't numbered isn't one this build wrote: it stays as it is,
|
||||
// unread, and a save keeps it.
|
||||
Some((_, None)) => kept.push(n),
|
||||
}
|
||||
}
|
||||
wb.names = kept;
|
||||
// Past the macro limit, the rest are counted without being read.
|
||||
bad = bad.saturating_add(stored.len().saturating_sub(gridcraft_model::MAX_MACROS));
|
||||
let mut found = Vec::new();
|
||||
let mut item = Workbook::new();
|
||||
for ((_, key), names) in stored.into_iter().take(gridcraft_model::MAX_MACROS) {
|
||||
item.names = names;
|
||||
let v = stash::get_json(&item, &key);
|
||||
if let Some(v) = v.as_ref().filter(|v| is_newer(v)) {
|
||||
keep_unread(&mut unread, v.clone(), &mut bad);
|
||||
continue;
|
||||
}
|
||||
match v.ok_or_else(|| "unreadable".to_string()).and_then(|v| parse_macro(&v)) {
|
||||
Ok(m) => found.push(m),
|
||||
Err(_) => bad += 1,
|
||||
}
|
||||
}
|
||||
// All of them go through the same limits, and a clashing name gets a suffix.
|
||||
let mut all = std::mem::take(&mut wb.macros);
|
||||
all.extend(found);
|
||||
let mut taken = Taken::default();
|
||||
for m in all {
|
||||
if wb.macros.len() >= gridcraft_model::MAX_MACROS {
|
||||
bad += 1;
|
||||
continue;
|
||||
}
|
||||
let mut m = m;
|
||||
let checked = m.check_limits().and_then(|()| {
|
||||
m.name = taken.unique(m.name.trim());
|
||||
// The macro as it is kept, renamed, must still pass, or the next save would lose it.
|
||||
check(&m)
|
||||
});
|
||||
match checked {
|
||||
Ok(()) => {
|
||||
taken.insert(&m.name);
|
||||
wb.macros.push(m);
|
||||
}
|
||||
Err(_) => bad += 1,
|
||||
}
|
||||
}
|
||||
// Readable and unread macros together stay within the limit, the readable ones first.
|
||||
let room = gridcraft_model::MAX_MACROS.saturating_sub(wb.macros.len());
|
||||
bad = bad.saturating_add(unread.len().saturating_sub(room));
|
||||
unread.truncate(room);
|
||||
if !unread.is_empty() {
|
||||
let one = unread.len() == 1;
|
||||
warnings.push(format!(
|
||||
"{} macro{} in the workbook {} made by a newer version of GridCraft: this version can't show or run {}, and saving keeps {} unchanged.",
|
||||
unread.len(),
|
||||
if one { "" } else { "s" },
|
||||
if one { "was" } else { "were" },
|
||||
if one { "it" } else { "them" },
|
||||
if one { "it" } else { "them" },
|
||||
));
|
||||
}
|
||||
wb.macros_unread = std::sync::Arc::new(unread);
|
||||
if bad > 0 {
|
||||
warnings.push(format!(
|
||||
"{bad} macro{} in the workbook couldn't be opened: {} damaged or over the limits ({} macros, {} steps each, Repeat and If nested {} deep).",
|
||||
if bad == 1 { "" } else { "s" },
|
||||
if bad == 1 { "it is" } else { "they are" },
|
||||
gridcraft_model::MAX_MACROS,
|
||||
gridcraft_model::MAX_MACRO_STEPS,
|
||||
gridcraft_model::MAX_MACRO_DEPTH
|
||||
));
|
||||
}
|
||||
warnings
|
||||
}
|
||||
|
||||
/// The item key (`macro_<n>`, lower-cased) of a hidden macro name, and `n` when it is a number.
|
||||
fn macro_item(n: &DefinedName) -> Option<(String, Option<u64>)> {
|
||||
// Only a hidden workbook-scope name is macro storage: a person's own visible name that
|
||||
// happens to look like one stays a name.
|
||||
let key = stash::item_key(&n.name).filter(|k| n.hidden && n.scope.is_none() && k.starts_with(MACRO_KEY))?;
|
||||
let i = key.strip_prefix(MACRO_KEY).and_then(|i| i.parse().ok());
|
||||
Some((key, i))
|
||||
}
|
||||
|
||||
/// `name`, or `name (2)`, `name (3)`… if a macro already has it, a kept one too. A free name comes back as it is;
|
||||
/// a renamed one is shortened to make room for the suffix, so it stays within
|
||||
/// [`gridcraft_model::MAX_MACRO_NAME`] characters. The result still goes through [`check`].
|
||||
pub fn unique_name(wb: &Workbook, name: &str) -> String {
|
||||
let mut taken = Taken::default();
|
||||
for m in &wb.macros {
|
||||
taken.insert(&m.name);
|
||||
}
|
||||
for kept in wb.macros_unread.iter().filter_map(|v| v.get("name").and_then(Json::as_str)) {
|
||||
taken.insert(kept);
|
||||
}
|
||||
taken.unique(name)
|
||||
}
|
||||
|
||||
/// Macro names in use, compared as [`find`] compares them, and the next suffix to try for each
|
||||
/// shortened name and suffix width, so naming many clashing macros takes near-linear work.
|
||||
#[derive(Default)]
|
||||
struct Taken {
|
||||
names: HashSet<String>,
|
||||
next: HashMap<(String, usize), u64>,
|
||||
/// Name lookups made, for tests.
|
||||
probes: usize,
|
||||
}
|
||||
|
||||
impl Taken {
|
||||
fn key(name: &str) -> String {
|
||||
name.trim().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn insert(&mut self, name: &str) {
|
||||
self.names.insert(Taken::key(name));
|
||||
}
|
||||
|
||||
fn contains(&mut self, name: &str) -> bool {
|
||||
self.probes = self.probes.saturating_add(1);
|
||||
self.names.contains(&Taken::key(name))
|
||||
}
|
||||
|
||||
/// The first of `name`, `name (2)`, `name (3)`… not in use.
|
||||
fn unique(&mut self, name: &str) -> String {
|
||||
let name = if name.is_empty() { "Macro" } else { name };
|
||||
if !self.contains(name) {
|
||||
return name.to_string();
|
||||
}
|
||||
let mut n: u64 = 2;
|
||||
loop {
|
||||
let suffix = format!(" ({n})");
|
||||
let keep = gridcraft_model::MAX_MACRO_NAME.saturating_sub(suffix.chars().count());
|
||||
let base: String = name.chars().take(keep).collect();
|
||||
let base = base.trim_end();
|
||||
// Every candidate for this base and suffix width below `next` is in use already
|
||||
// (names are only added), so the search goes on from there.
|
||||
let key = (Taken::key(base), suffix.len());
|
||||
let next = self.next.get(&key).copied().unwrap_or(n);
|
||||
if next > n {
|
||||
n = next;
|
||||
continue;
|
||||
}
|
||||
let Some(after) = n.checked_add(1) else { return name.to_string() };
|
||||
self.next.insert(key, after);
|
||||
let candidate = format!("{base}{suffix}");
|
||||
if !self.contains(&candidate) {
|
||||
return candidate;
|
||||
}
|
||||
n = after;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ctrl+Shift (Cmd+Shift on a Mac) letters that GridCraft already uses, on any platform, which a
|
||||
/// macro cannot take.
|
||||
pub const RESERVED_SHORTCUTS: &[(char, &str)] = &[
|
||||
('F', "Format Cells (Font) or Filter"),
|
||||
('G', "Workbook Statistics"),
|
||||
('H', "Replace"),
|
||||
('J', "Ungroup"),
|
||||
('K', "Group"),
|
||||
('L', "Filter"),
|
||||
('R', "Sort"),
|
||||
('S', "Save As"),
|
||||
('T', "AutoSum"),
|
||||
('Z', "Redo"),
|
||||
];
|
||||
|
||||
/// The letter of a macro shortcut: `"Ctrl+Shift+M"`, `"Cmd+Shift+M"`, `"⌘⇧M"` and `"m"` all give
|
||||
/// `'M'`. `None` when it is not one letter from A to Z, alone or after Ctrl+Shift (Cmd+Shift):
|
||||
/// other modifiers (`"Ctrl+Alt+M"`) are refused rather than read as Ctrl+Shift.
|
||||
pub fn parse_shortcut(s: &str) -> Option<char> {
|
||||
let letter = |t: &str| {
|
||||
let mut chars = t.trim().chars();
|
||||
match (chars.next(), chars.next()) {
|
||||
(Some(c), None) if c.is_ascii_alphabetic() => Some(c.to_ascii_uppercase()),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let t = s.trim().to_ascii_lowercase();
|
||||
["ctrl+shift+", "cmd+shift+", "⌘⇧"].iter().find_map(|p| t.strip_prefix(p)).map_or_else(|| letter(&t), letter)
|
||||
}
|
||||
|
||||
/// How a shortcut is stored and shown: `Ctrl+Shift+M`.
|
||||
pub fn shortcut_text(letter: char) -> String {
|
||||
format!("Ctrl+Shift+{}", letter.to_ascii_uppercase())
|
||||
}
|
||||
|
||||
/// Why macro `name` cannot use Ctrl+Shift+`letter`: GridCraft uses it, or another macro has it.
|
||||
pub fn shortcut_conflict(wb: &Workbook, name: &str, letter: char) -> Option<String> {
|
||||
let letter = letter.to_ascii_uppercase();
|
||||
if let Some((_, what)) = RESERVED_SHORTCUTS.iter().find(|(c, _)| *c == letter) {
|
||||
return Some(format!("{} is {what}. Choose another letter.", shortcut_text(letter)));
|
||||
}
|
||||
wb.macros
|
||||
.iter()
|
||||
.find(|m| !m.name.eq_ignore_ascii_case(name.trim()) && m.shortcut.as_deref().and_then(parse_shortcut) == Some(letter))
|
||||
.map(|m| format!("{} already runs macro “{}”. Choose another letter.", shortcut_text(letter), m.name))
|
||||
}
|
||||
|
||||
/// `name` trimmed, if it can name a new macro in `wb`; otherwise why not: it is empty, too long,
|
||||
/// holds line breaks or another macro has it.
|
||||
pub fn check_new_name(wb: &Workbook, name: &str) -> Result<String, String> {
|
||||
let name = name.trim();
|
||||
let probe = Macro { name: name.to_string(), ..Macro::default() };
|
||||
probe.check_limits()?;
|
||||
if name.chars().any(char::is_control) {
|
||||
return Err("A macro name cannot hold line breaks or other control characters.".into());
|
||||
}
|
||||
if find(wb, name).is_some() {
|
||||
return Err(format!("A macro called “{name}” already exists. Choose another name."));
|
||||
}
|
||||
if let Some(kept) = kept_name(wb, name) {
|
||||
return Err(format!("A macro called “{kept}” is kept from a newer version of GridCraft. Choose another name."));
|
||||
}
|
||||
Ok(name.to_string())
|
||||
}
|
||||
|
||||
/// The first free `Macro1`, `Macro2`… name, also free among the kept macros ([`kept_name`]).
|
||||
pub fn next_name(wb: &Workbook) -> String {
|
||||
(1..).map(|n| format!("Macro{n}")).find(|n| find(wb, n).is_none() && kept_name(wb, n).is_none()).unwrap_or_else(|| "Macro".into())
|
||||
}
|
||||
|
||||
/// Every command step of `steps` (inside Repeat and If too), with its path.
|
||||
pub fn command_steps(steps: &[Step]) -> Vec<(String, &str, &Json)> {
|
||||
fn walk<'a>(steps: &'a [Step], prefix: &str, out: &mut Vec<(String, &'a str, &'a Json)>) {
|
||||
for (i, st) in steps.iter().enumerate() {
|
||||
let path = if prefix.is_empty() { (i + 1).to_string() } else { format!("{prefix}.{}", i + 1) };
|
||||
match &st.kind {
|
||||
StepKind::Command { id, params } => out.push((path, id.as_str(), params)),
|
||||
StepKind::Repeat { body, .. } => walk(body, &path, out),
|
||||
StepKind::If { then, otherwise, .. } => {
|
||||
walk(then, &format!("{path}.then"), out);
|
||||
walk(otherwise, &format!("{path}.else"), out);
|
||||
}
|
||||
StepKind::Stop { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
walk(steps, "", &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,148 @@
|
||||
//! What a macro step may run. Macros run commands, so this keeps a macro from saving, opening or
|
||||
//! closing files, driving the app, running other macros, or reading and writing files without the
|
||||
//! person knowing. It goes by command id and the `path` parameter; the runner also runs every
|
||||
//! step except an approved file step inside the scripts' file sandbox, so a command that touches a
|
||||
//! file under another parameter can't either.
|
||||
|
||||
use serde_json::Value as Json;
|
||||
|
||||
use crate::Trust;
|
||||
|
||||
/// Whether a command may run as a macro step.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Policy {
|
||||
Allow,
|
||||
/// Allowed once the person approves the macro; the text says why it asks ("came with a
|
||||
/// workbook you opened").
|
||||
Ask(String),
|
||||
/// Reads or writes files: the macro must list the command in its permissions, and the person
|
||||
/// approves the macro before it runs. The text says what it does ("reads a file").
|
||||
NeedsPermission(String),
|
||||
/// Never runs in a macro; the text says why.
|
||||
Deny(String),
|
||||
}
|
||||
|
||||
/// Prefixes of commands that never run in a macro, with the reason.
|
||||
const DENIED_PREFIXES: &[(&str, &str)] = &[
|
||||
("file.", "macros don't open, save, close or create workbooks"),
|
||||
("app.", "macros don't change the app"),
|
||||
("window.", "macros don't switch windows"),
|
||||
("automate.", "macros don't record or run scripts or other macros"),
|
||||
("macro.", "macros don't record or run other macros"),
|
||||
];
|
||||
|
||||
/// Single commands that never run in a macro.
|
||||
const DENIED: &[(&str, &str)] = &[
|
||||
("edit.undo", "a macro is undone as one step after it runs"),
|
||||
("edit.redo", "a macro is undone as one step after it runs"),
|
||||
("view.newWindow", "macros don't open, hide or show windows"),
|
||||
("view.hideWindow", "macros don't open, hide or show windows"),
|
||||
("view.unhideWindow", "macros don't open, hide or show windows"),
|
||||
];
|
||||
|
||||
/// Commands under a denied prefix that only change the workbook.
|
||||
const ALLOWED: &[&str] = &["file.properties"];
|
||||
|
||||
/// Commands that read a file when given a `path`.
|
||||
const READS_FILES: &[&str] = &["data.getData", "data.fromTextCsv", "insert.picture", "pageLayout.background"];
|
||||
|
||||
/// File commands that only produce bytes unless given a `path` (then they write a file).
|
||||
const EXPORTS: &[&str] = &["file.exportPdf", "file.exportCsv", "file.exportHtml", "file.print", "file.printPreview", "file.saveBytes"];
|
||||
|
||||
/// Whether command `id` with `params` writes a file (an export or print given a `path`).
|
||||
pub fn writes_file(id: &str, params: &Json) -> bool {
|
||||
EXPORTS.contains(&id) && params.get("path").is_some_and(|p| !p.is_null())
|
||||
}
|
||||
|
||||
/// The policy for running command `id` with `params` as a macro step in a document with `trust`.
|
||||
///
|
||||
/// Any `path` other than `null` counts as touching a file: a token such as `{"$ref": "Input"}`
|
||||
/// becomes a path when the step runs, and exports write to `""` and `" "` as file names. The
|
||||
/// runner checks the parameters both as written and as resolved.
|
||||
pub fn macro_policy(id: &str, params: &Json, trust: Trust) -> Policy {
|
||||
let has_path = params.get("path").is_some_and(|p| !p.is_null());
|
||||
let base = if EXPORTS.contains(&id) {
|
||||
if has_path { Policy::NeedsPermission("writes a file".into()) } else { Policy::Allow }
|
||||
} else if ALLOWED.contains(&id) {
|
||||
Policy::Allow
|
||||
} else if let Some((_, why)) = DENIED.iter().find(|(d, _)| *d == id) {
|
||||
Policy::Deny((*why).into())
|
||||
} else if let Some((_, why)) = DENIED_PREFIXES.iter().find(|(p, _)| id.starts_with(p)) {
|
||||
Policy::Deny((*why).into())
|
||||
} else if READS_FILES.contains(&id) && has_path {
|
||||
Policy::NeedsPermission("reads a file".into())
|
||||
} else {
|
||||
Policy::Allow
|
||||
};
|
||||
match base {
|
||||
Policy::Allow if !trust.allows_code() => Policy::Ask("came with a workbook you opened".into()),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the footprint dry run can preview a command. Solver can take long, so a dry run stops
|
||||
/// before it and reports the footprint as partial.
|
||||
pub fn previewable(id: &str) -> bool {
|
||||
id != "data.solver"
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn the_deny_and_permission_lists() {
|
||||
let none = json!({});
|
||||
let path = json!({"path": "/tmp/x"});
|
||||
for id in [
|
||||
"file.saveAs",
|
||||
"file.save",
|
||||
"file.open",
|
||||
"file.close",
|
||||
"file.new",
|
||||
"app.commands",
|
||||
"window.activate",
|
||||
"automate.runScript",
|
||||
"automate.recordActions",
|
||||
"macro.run",
|
||||
"macro.record",
|
||||
"edit.undo",
|
||||
"edit.redo",
|
||||
"view.newWindow",
|
||||
"view.hideWindow",
|
||||
"view.unhideWindow",
|
||||
] {
|
||||
assert!(matches!(macro_policy(id, &none, Trust::Local), Policy::Deny(_)), "{id}");
|
||||
assert!(matches!(macro_policy(id, &path, Trust::FromFile), Policy::Deny(_)), "{id}");
|
||||
}
|
||||
for id in ["data.getData", "data.fromTextCsv", "insert.picture", "pageLayout.background", "file.exportPdf", "file.print", "file.exportCsv"] {
|
||||
assert!(matches!(macro_policy(id, &path, Trust::Local), Policy::NeedsPermission(_)), "{id}");
|
||||
assert!(matches!(macro_policy(id, &path, Trust::FromFile), Policy::NeedsPermission(_)), "{id}");
|
||||
}
|
||||
// Without a path they touch no file.
|
||||
for id in ["data.getData", "insert.picture", "file.exportPdf", "file.saveBytes", "file.printPreview"] {
|
||||
assert_eq!(macro_policy(id, &none, Trust::Local), Policy::Allow, "{id}");
|
||||
assert_eq!(macro_policy(id, &json!({"path": null}), Trust::Local), Policy::Allow, "{id}");
|
||||
}
|
||||
// A path that is blank or a token still touches a file once the step runs.
|
||||
for path in [json!(""), json!(" "), json!({"$ref": "Input"}), json!("{{Input}}.csv"), json!(1)] {
|
||||
for id in ["file.exportCsv", "data.fromTextCsv"] {
|
||||
let p = json!({ "path": path });
|
||||
assert!(matches!(macro_policy(id, &p, Trust::Local), Policy::NeedsPermission(_)), "{id} {p}");
|
||||
}
|
||||
}
|
||||
for id in ["cell.set", "home.bold", "view.macros", "edit.copy", "file.properties"] {
|
||||
assert_eq!(macro_policy(id, &none, Trust::Local), Policy::Allow, "{id}");
|
||||
assert!(matches!(macro_policy(id, &none, Trust::FromFile), Policy::Ask(_)), "{id}");
|
||||
}
|
||||
assert!(!previewable("data.solver") && previewable("cell.set"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_listed_command_exists() {
|
||||
for id in ALLOWED.iter().chain(READS_FILES).chain(EXPORTS).chain(DENIED.iter().map(|(id, _)| id)).chain(&["data.solver"]) {
|
||||
assert!(crate::find_command(id).is_some(), "{id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
//! Turning a recording into a macro.
|
||||
//!
|
||||
//! The recording buffer (`crate::record`) holds the top-level commands run while recording, each
|
||||
//! with the sheet and selection it ran in. When recording stops:
|
||||
//!
|
||||
//! - commands a macro may not run (saving, opening files…) and other macros' commands are left
|
||||
//! out; a macro run while recording is replaced by its steps when it has no inputs, no output
|
||||
//! and only plain commands (otherwise it is left out), and an Automate script run while
|
||||
//! recording by the steps it ran, leaving out the steps a macro may not run; both are the steps
|
||||
//! as they ran, kept with the recording ([`crate::record::Recorder::ran`]), and a run without
|
||||
//! them is left out;
|
||||
//! - a `sheet.activate` or `selection.set` step is added where the sheet or selection changed
|
||||
//! between commands without one, and runs of `selection.set` collapse to the last;
|
||||
//! - with relative references (the default), the selection when recording started is the example
|
||||
//! input: references on its sheet in, next to or near it become tokens (`Input.col(2)`,
|
||||
//! `Input.below`, `Input.cell(1,4)`), also inside formulas (`{{Input.col(2)}}`), and the input's
|
||||
//! shape, header and column types are inferred from it as it was when recording started;
|
||||
//! - recorded text that playback would read as a token stays text: a reference parameter that
|
||||
//! reads as one (a defined name called `Output`) becomes `{"$text": …}`, and `{{` in a string
|
||||
//! becomes `{{{{`.
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef, Value};
|
||||
use gridcraft_model::{ColType, InputKind, MAX_MACRO_STEPS, Macro, NamedInput, Step, StepKind, Workbook};
|
||||
use serde_json::{Map, Value as Json, json};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::policy::{Policy, macro_policy};
|
||||
use crate::Selection;
|
||||
use crate::record::RecordedStep;
|
||||
|
||||
/// How far outside the example input a reference may be and still be made relative to it.
|
||||
const NEAR_ROWS: u32 = 50;
|
||||
const NEAR_COLS: u32 = 26;
|
||||
|
||||
/// Where recording started, kept beside the buffer, with the name, shortcut and description the
|
||||
/// macro is to be saved with (from the Record Macro dialog), applied when recording stops.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RecordingStart {
|
||||
pub doc: u64,
|
||||
pub sheet: usize,
|
||||
pub sheet_name: String,
|
||||
pub selection: Selection,
|
||||
pub relative: bool,
|
||||
/// With relative references, the example input (the selection) as it was when recording
|
||||
/// started: the recorded steps may change its values (text made into numbers), and the
|
||||
/// macro is for data like the data it was recorded on.
|
||||
pub input: Option<NamedInput>,
|
||||
/// The name, shortcut and description given when recording started (Record Macro).
|
||||
pub name: Option<String>,
|
||||
pub shortcut: Option<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
impl RecordingStart {
|
||||
/// Recording starting now in `d`.
|
||||
pub fn new(d: &crate::DocState, relative: bool) -> RecordingStart {
|
||||
let sheet = d.wb.active_sheet();
|
||||
RecordingStart {
|
||||
doc: d.uid,
|
||||
sheet,
|
||||
sheet_name: d.wb.active().map(|sh| sh.name.clone()).unwrap_or_default(),
|
||||
selection: d.selection.clone(),
|
||||
relative,
|
||||
input: relative.then(|| infer_input(&d.wb, sheet, d.selection.current())),
|
||||
name: None,
|
||||
shortcut: None,
|
||||
description: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The steps a recording keeps for a run of `m`: its enabled command steps as written, when they
|
||||
/// stand on their own (no inputs, no output and only plain commands; a Repeat, If or Stop means
|
||||
/// nothing outside its macro). `None` when they don't, and the recording leaves the run out.
|
||||
pub fn inline_steps(m: &Macro) -> Option<Vec<(String, Json)>> {
|
||||
if !m.inputs.is_empty() || m.output.anchor.is_some() || !m.steps.iter().all(|x| matches!(x.kind, StepKind::Command { .. })) {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
m.steps
|
||||
.iter()
|
||||
.filter(|x| x.enabled)
|
||||
.filter_map(|x| match &x.kind {
|
||||
StepKind::Command { id, params } => Some((id.clone(), params.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Where a gathered command came from, which says how its parameters are written.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Origin {
|
||||
/// Run while recording: plain parameters, which may become relative.
|
||||
Recorded,
|
||||
/// A step of a recorded script run: plain parameters, kept as they are.
|
||||
Script,
|
||||
/// A step of a recorded macro run: macro parameters (tokens and all), kept as written.
|
||||
Macro,
|
||||
}
|
||||
|
||||
/// A recording turned into a macro, with what was left out.
|
||||
pub struct Converted {
|
||||
pub mac: Macro,
|
||||
/// Commands left out (ids), in order.
|
||||
pub skipped: Vec<String>,
|
||||
/// Steps not kept: past the macro's step limit (a recorded run of a long macro adds all of
|
||||
/// its steps) or recorded in another workbook.
|
||||
pub cut: usize,
|
||||
}
|
||||
|
||||
/// Builds a macro called `name` from the recorded steps.
|
||||
/// `ran` is what a macro or script run ran, by step index ([`crate::record::Recorder::ran`]); a
|
||||
/// run without an entry is left out and listed in `skipped`.
|
||||
pub fn convert(name: &str, start: &RecordingStart, steps: &[RecordedStep], ran: &BTreeMap<usize, Vec<(String, Json)>>) -> Converted {
|
||||
let mut skipped = Vec::new();
|
||||
// Commands as they'll be played back: (id, params, sheet before, selection before, where
|
||||
// they came from). At most the macro's step limit are gathered: a recording of a thousand
|
||||
// runs of a long macro would otherwise expand to millions of steps before the limit cut them
|
||||
// back.
|
||||
let mut cmds: Vec<(String, Json, usize, String, Selection, Origin)> = Vec::new();
|
||||
let mut cut = steps.iter().filter(|st| st.doc != start.doc).count();
|
||||
let refused = |id: &str, params: &Json| matches!(macro_policy(id, params, crate::Trust::Local), Policy::Deny(_) | Policy::NeedsPermission(_));
|
||||
let mut push = |cmds: &mut Vec<(String, Json, usize, String, Selection, Origin)>, cmd: (String, Json, usize, String, Selection, Origin)| {
|
||||
if cmds.len() < MAX_MACRO_STEPS {
|
||||
cmds.push(cmd);
|
||||
} else {
|
||||
cut = cut.saturating_add(1);
|
||||
}
|
||||
};
|
||||
for (index, st) in steps.iter().enumerate().filter(|(_, st)| st.doc == start.doc) {
|
||||
let id = st.id.as_str();
|
||||
// Another macro's steps (when they stand on their own, [`inline_steps`]) or an Automate
|
||||
// script's, as they ran: what the run kept with the recording. Without them the run is
|
||||
// left out; the macro or script as it stands now may not be what ran.
|
||||
let origin = match id {
|
||||
"macro.run" => Some(Origin::Macro),
|
||||
"automate.runScript" => Some(Origin::Script),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(origin) = origin {
|
||||
match ran.get(&index) {
|
||||
Some(steps) => {
|
||||
for (cid, params) in steps {
|
||||
if refused(cid, params) {
|
||||
skipped.push(cid.clone());
|
||||
continue;
|
||||
}
|
||||
push(&mut cmds, (cid.clone(), params.clone(), st.sheet, st.sheet_name.clone(), st.selection.clone(), origin));
|
||||
}
|
||||
}
|
||||
None => skipped.push(id.to_string()),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if refused(id, &st.params) {
|
||||
skipped.push(id.to_string());
|
||||
continue;
|
||||
}
|
||||
push(&mut cmds, (id.to_string(), st.params.clone(), st.sheet, st.sheet_name.clone(), st.selection.clone(), Origin::Recorded));
|
||||
}
|
||||
|
||||
// Sheet and selection changes made without a command: where a command ran on another sheet,
|
||||
// or with another selection, than the one before it, say so (a command that moved the
|
||||
// selection itself does it again when played back, so the extra step is harmless).
|
||||
// Each step keeps the name of the sheet it runs on.
|
||||
let mut out: Vec<(String, Json, String, Origin)> = Vec::new();
|
||||
let (mut sheet, mut sel) = (start.sheet, start.selection.clone());
|
||||
for (id, params, at_sheet, sheet_name, at_sel, origin) in cmds {
|
||||
if at_sheet != sheet {
|
||||
out.push(("sheet.activate".into(), json!({"sheet": sheet_name}), sheet_name.clone(), Origin::Recorded));
|
||||
}
|
||||
// A recorded selection.set just before already put the selection there.
|
||||
let selected = matches!(out.last(), Some((last, ..)) if last == "selection.set");
|
||||
if (at_sel != sel || at_sheet != sheet) && !selected {
|
||||
out.push(("selection.set".into(), selection_params(&at_sel), sheet_name.clone(), Origin::Recorded));
|
||||
}
|
||||
(sheet, sel) = (at_sheet, at_sel);
|
||||
out.push((id, params, sheet_name, origin));
|
||||
}
|
||||
// Runs of selection.set (or sheet.activate): the last one wins.
|
||||
let mut collapsed: Vec<(String, Json, String, Origin)> = Vec::new();
|
||||
for (id, params, on, origin) in out {
|
||||
if matches!(id.as_str(), "selection.set" | "sheet.activate")
|
||||
&& let Some((last, ..)) = collapsed.last()
|
||||
&& *last == id
|
||||
{
|
||||
collapsed.pop();
|
||||
}
|
||||
collapsed.push((id, params, on, origin));
|
||||
}
|
||||
|
||||
// Relative references. Only references on the input's sheet become tokens: on another sheet
|
||||
// `C4` is that sheet's C4.
|
||||
let example = start.selection.current();
|
||||
let mut used_input = false;
|
||||
let steps: Vec<Step> = collapsed
|
||||
.into_iter()
|
||||
.map(|(id, params, on, origin)| {
|
||||
// A step that names another sheet with its `sheet` parameter runs there, whatever
|
||||
// sheet was active: its unqualified references are that sheet's cells.
|
||||
let names_other_sheet = params.get("sheet").is_some_and(|v| match v {
|
||||
Json::String(n) => !n.eq_ignore_ascii_case(&start.sheet_name),
|
||||
Json::Number(i) => i.as_u64() != Some(start.sheet as u64),
|
||||
_ => false,
|
||||
});
|
||||
let here = on.eq_ignore_ascii_case(&start.sheet_name) && !names_other_sheet;
|
||||
// Plain text first, so the tokens added next are the only ones.
|
||||
let params = if origin == Origin::Macro { params } else { literal(¶ms, None) };
|
||||
let params = if start.relative && origin == Origin::Recorded {
|
||||
relative(¶ms, example, &start.sheet_name, here, &mut used_input, None)
|
||||
} else {
|
||||
params
|
||||
};
|
||||
Step::command(id, params)
|
||||
})
|
||||
.collect();
|
||||
let mut mac = Macro { name: name.to_string(), steps, ..Macro::default() };
|
||||
if start.relative && used_input {
|
||||
mac.inputs = vec![start.input.clone().unwrap_or_default()];
|
||||
}
|
||||
Converted { mac, skipped, cut }
|
||||
}
|
||||
|
||||
fn selection_params(sel: &Selection) -> Json {
|
||||
let ranges: Vec<String> = sel.ranges.iter().map(RangeRef::a1).collect();
|
||||
json!({"range": ranges.join(","), "active": sel.active.a1()})
|
||||
}
|
||||
|
||||
/// Plain recorded `params` written so playback reads them as they are: a string in a reference
|
||||
/// parameter that playback would read as a token, or that holds `{{`, becomes `{"$text": …}`,
|
||||
/// and `{{` in any other string becomes `{{{{`.
|
||||
fn literal(v: &Json, key: Option<&str>) -> Json {
|
||||
match v {
|
||||
Json::Object(o) => Json::Object(o.iter().map(|(k, x)| (k.clone(), literal(x, Some(k)))).collect()),
|
||||
Json::Array(a) => Json::Array(a.iter().map(|x| literal(x, key)).collect()),
|
||||
Json::String(t) if key.is_some_and(super::resolve::is_ref_key) && (t.contains("{{") || super::resolve::reads_as_token(t, &["Input"])) => {
|
||||
json!({"$text": t})
|
||||
}
|
||||
Json::String(t) if t.contains("{{") => json!(t.replace("{{", "{{{{")),
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `params` with references near `example` (on `sheet`) made into tokens; `here` says whether the
|
||||
/// step runs on that sheet, so its unqualified references are on it.
|
||||
fn relative(v: &Json, example: RangeRef, sheet: &str, here: bool, used: &mut bool, key: Option<&str>) -> Json {
|
||||
match v {
|
||||
// Text kept as text ([`literal`]).
|
||||
Json::Object(o) if o.len() == 1 && o.contains_key("$text") => v.clone(),
|
||||
Json::Object(o) => {
|
||||
// A selection of several areas stays as it is, its active cell with it: a token for
|
||||
// the active cell alone would put it outside the areas on playback.
|
||||
let several_areas = o.get("range").and_then(Json::as_str).is_some_and(|r| r.contains(','));
|
||||
let mut out = Map::new();
|
||||
for (k, x) in o {
|
||||
let x = if several_areas && k == "active" { x.clone() } else { relative(x, example, sheet, here, used, Some(k)) };
|
||||
out.insert(k.clone(), x);
|
||||
}
|
||||
Json::Object(out)
|
||||
}
|
||||
Json::Array(a) => Json::Array(a.iter().map(|x| relative(x, example, sheet, here, used, key)).collect()),
|
||||
Json::String(t) if key.is_some_and(super::resolve::is_ref_key) => {
|
||||
// One area becomes a token; a list of areas ("A1:B2,D4") stays as it is.
|
||||
if t.contains(',') {
|
||||
return v.clone();
|
||||
}
|
||||
match ref_token(t, example, sheet, here) {
|
||||
Some(token) => {
|
||||
*used = true;
|
||||
json!(token)
|
||||
}
|
||||
None => v.clone(),
|
||||
}
|
||||
}
|
||||
Json::String(t) if here && t.starts_with('=') && example.count() > 1 => {
|
||||
let (f, changed) = formula_tokens(t, example);
|
||||
*used |= changed;
|
||||
json!(f)
|
||||
}
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The token for an A1 reference on the example's sheet near the example input. `here` says whether
|
||||
/// an unqualified reference is on that sheet.
|
||||
fn ref_token(text: &str, example: RangeRef, sheet: &str, here: bool) -> Option<String> {
|
||||
let (on, body) = crate::cmd::split_sheet(text.trim());
|
||||
match on {
|
||||
Some(on) if !on.eq_ignore_ascii_case(sheet) => return None,
|
||||
None if !here => return None,
|
||||
_ => {}
|
||||
}
|
||||
let t = RangeRef::parse(body)?;
|
||||
if t.is_full_cols() || t.is_full_rows() {
|
||||
return None;
|
||||
}
|
||||
token(t, example)
|
||||
}
|
||||
|
||||
/// `t` as a token relative to `r`.
|
||||
fn token(t: RangeRef, r: RangeRef) -> Option<String> {
|
||||
if t == r {
|
||||
return Some("Input".into());
|
||||
}
|
||||
let rel_row = |row: u32| i64::from(row) - i64::from(r.start.row) + 1;
|
||||
let rel_col = |col: u32| i64::from(col) - i64::from(r.start.col) + 1;
|
||||
let cols = |a: u32, b: u32| if a == b { format!("col({})", rel_col(a)) } else { format!("cols({}..{})", rel_col(a), rel_col(b)) };
|
||||
if r.contains_range(&t) {
|
||||
let full_rows = t.start.row == r.start.row && t.end.row == r.end.row;
|
||||
let full_cols = t.start.col == r.start.col && t.end.col == r.end.col;
|
||||
return Some(if full_rows {
|
||||
format!("Input.{}", cols(t.start.col, t.end.col))
|
||||
} else if full_cols && t.start.row == t.end.row {
|
||||
format!("Input.row({})", rel_row(t.start.row))
|
||||
} else if full_cols && t.end.row == r.end.row {
|
||||
format!("Input.rows({}..)", rel_row(t.start.row))
|
||||
} else if full_cols {
|
||||
format!("Input.rows({}..{})", rel_row(t.start.row), rel_row(t.end.row))
|
||||
} else if t.is_single() {
|
||||
format!("Input.cell({},{})", rel_row(t.start.row), rel_col(t.start.col))
|
||||
} else {
|
||||
format!("Input.cell({},{}).resize({},{})", rel_row(t.start.row), rel_col(t.start.col), t.height(), t.width())
|
||||
});
|
||||
}
|
||||
let near = t.start.row.saturating_add(NEAR_ROWS) >= r.start.row
|
||||
&& t.end.row <= r.end.row.saturating_add(NEAR_ROWS)
|
||||
&& t.start.col.saturating_add(NEAR_COLS) >= r.start.col
|
||||
&& t.end.col <= r.end.col.saturating_add(NEAR_COLS);
|
||||
if !near {
|
||||
return None;
|
||||
}
|
||||
// Just below the input, in its columns: follows the input's length.
|
||||
if t.start.row == r.end.row + 1 && t.start.col >= r.start.col && t.end.col <= r.end.col {
|
||||
let base = if t.start.col == r.start.col && t.end.col == r.end.col {
|
||||
"Input".to_string()
|
||||
} else {
|
||||
format!("Input.{}", cols(t.start.col, t.end.col))
|
||||
};
|
||||
return Some(if t.height() == 1 { format!("{base}.below") } else { format!("{base}.below({})", t.height()) });
|
||||
}
|
||||
// Just right of the input, in its rows: follows its width.
|
||||
if t.start.col == r.end.col + 1 && t.start.row >= r.start.row && t.end.row <= r.end.row {
|
||||
let base = if t.start.row == r.start.row && t.end.row == r.end.row {
|
||||
"Input".to_string()
|
||||
} else if t.start.row == t.end.row {
|
||||
format!("Input.row({})", rel_row(t.start.row))
|
||||
} else {
|
||||
format!("Input.rows({}..{})", rel_row(t.start.row), rel_row(t.end.row))
|
||||
};
|
||||
return Some(if t.width() == 1 { format!("{base}.right") } else { format!("{base}.right({})", t.width()) });
|
||||
}
|
||||
let cell = format!("Input.cell({},{})", rel_row(t.start.row), rel_col(t.start.col));
|
||||
Some(if t.is_single() { cell } else { format!("{cell}.resize({},{})", t.height(), t.width()) })
|
||||
}
|
||||
|
||||
/// Replaces references in a formula that are exactly the example input, one of its columns or
|
||||
/// rows, or a column without its first row, with `{{token}}`.
|
||||
fn formula_tokens(f: &str, r: RangeRef) -> (String, bool) {
|
||||
// Each reference in the formula text is read and matched on its own, so the work is the
|
||||
// formula's length whatever the example's size (an example of a whole sheet has a million
|
||||
// rows, which no list of candidates may grow with).
|
||||
let mut out = String::with_capacity(f.len());
|
||||
let mut changed = false;
|
||||
let (mut i, mut in_text) = (0, false);
|
||||
let bytes = f.as_bytes();
|
||||
while i < f.len() {
|
||||
let Some(rest) = f.get(i..) else { break };
|
||||
let ch = rest.chars().next().unwrap_or(' ');
|
||||
if ch == '"' {
|
||||
in_text = !in_text;
|
||||
}
|
||||
// A reference right after `{` stays as it is: `{{` before the token would read as the
|
||||
// escape of a literal `{{`.
|
||||
let prev_ok = i == 0 || !bytes.get(i - 1).is_some_and(|b| b.is_ascii_alphanumeric() || matches!(b, b'$' | b'!' | b'_' | b'.' | b':' | b'{'));
|
||||
if !in_text && prev_ok && ch.is_ascii_alphabetic() {
|
||||
// The longest run of reference characters here: letters, digits and one `:`.
|
||||
let n = rest.bytes().take_while(|b| b.is_ascii_alphanumeric() || *b == b':').count();
|
||||
let text = rest.get(..n).unwrap_or("");
|
||||
let followed = rest.get(n..).and_then(|x| x.chars().next()).is_some_and(|c| matches!(c, '!' | '(' | '_' | '$'));
|
||||
if !followed
|
||||
&& !text.contains('$')
|
||||
&& let Some(t) = RangeRef::parse(text)
|
||||
&& let Some(tok) = formula_token(t, r)
|
||||
{
|
||||
out.push_str("{{");
|
||||
out.push_str(&tok);
|
||||
out.push_str("}}");
|
||||
i += n;
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(ch);
|
||||
i += ch.len_utf8();
|
||||
}
|
||||
(out, changed)
|
||||
}
|
||||
|
||||
/// The token for a reference inside a formula: exactly the example input, one of its columns or
|
||||
/// rows, or a column without its first row. Other references stay as they are.
|
||||
fn formula_token(t: RangeRef, r: RangeRef) -> Option<String> {
|
||||
if t == r {
|
||||
return Some("Input".into());
|
||||
}
|
||||
if !r.contains_range(&t) {
|
||||
return None;
|
||||
}
|
||||
let col = usize::try_from(t.start.col.checked_sub(r.start.col)?).ok()?.checked_add(1)?;
|
||||
let row = usize::try_from(t.start.row.checked_sub(r.start.row)?).ok()?.checked_add(1)?;
|
||||
if t.start.col == t.end.col && t.start.row == r.start.row && t.end.row == r.end.row {
|
||||
return Some(format!("Input.col({col})"));
|
||||
}
|
||||
if t.start.col == t.end.col && r.height() > 2 && t.start.row == r.start.row.checked_add(1)? && t.end.row == r.end.row {
|
||||
return Some(format!("Input.rows(2..).col({col})"));
|
||||
}
|
||||
if r.width() > 1 && t.start.row == t.end.row && t.start.col == r.start.col && t.end.col == r.end.col {
|
||||
return Some(format!("Input.row({row})"));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The example input's shape: a header when the first row is all text over other kinds of
|
||||
/// values, and each column's type when all its values agree.
|
||||
pub fn infer_input(wb: &Workbook, sheet: usize, r: RangeRef) -> NamedInput {
|
||||
let mut input = NamedInput::default();
|
||||
if r.is_single() {
|
||||
input.kind = InputKind::Cell;
|
||||
input.rows = [1, 1];
|
||||
input.cols = [1, 1];
|
||||
return input;
|
||||
}
|
||||
input.cols = [r.width(), r.width()];
|
||||
let Some(sh) = wb.sheet(sheet) else { return input };
|
||||
if r.count() > 100_000 {
|
||||
return input;
|
||||
}
|
||||
let kind = |c: CellRef| -> Option<ColType> {
|
||||
let v = sh.value(c);
|
||||
Some(match v {
|
||||
Value::Empty => return None,
|
||||
Value::Text(t) if t.is_empty() => return None,
|
||||
Value::Number(_) if crate::display::number_format(wb.styles.get(sh.style_id(c)).num_fmt.as_str()).is_date() => ColType::Date,
|
||||
Value::Number(_) => ColType::Number,
|
||||
Value::Text(_) => ColType::Text,
|
||||
Value::Bool(_) => ColType::Bool,
|
||||
_ => ColType::Any,
|
||||
})
|
||||
};
|
||||
let column = |col: u32, from: u32| -> ColType {
|
||||
let mut seen: Option<ColType> = None;
|
||||
for row in from..=r.end.row {
|
||||
match (seen, kind(CellRef::new(row, col))) {
|
||||
(_, None) => {}
|
||||
(None, k) => seen = k,
|
||||
(Some(a), Some(b)) if a != b => return ColType::Any,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
seen.unwrap_or(ColType::Any)
|
||||
};
|
||||
let first_text = (r.start.col..=r.end.col).all(|c| kind(CellRef::new(r.start.row, c)) == Some(ColType::Text));
|
||||
let below = (r.start.col..=r.end.col).map(|c| column(c, r.start.row + 1)).collect::<Vec<_>>();
|
||||
input.header = r.height() > 1 && first_text && below.iter().any(|k| !matches!(k, ColType::Text | ColType::Any));
|
||||
input.col_types = if input.header { below } else { (r.start.col..=r.end.col).map(|c| column(c, r.start.row)).collect() };
|
||||
if input.col_types.iter().all(|k| *k == ColType::Any) {
|
||||
input.col_types.clear();
|
||||
}
|
||||
input.rows = [1, 0];
|
||||
input
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn r(a1: &str) -> RangeRef {
|
||||
RangeRef::parse(a1).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn references_near_the_example_become_tokens() {
|
||||
let ex = r("B2:D11");
|
||||
for (t, want) in [
|
||||
("B2:D11", Some("Input")),
|
||||
("C2:C11", Some("Input.col(2)")),
|
||||
("C2:D11", Some("Input.cols(2..3)")),
|
||||
("B2:D2", Some("Input.row(1)")),
|
||||
("B3:D11", Some("Input.rows(2..)")),
|
||||
("B3:D5", Some("Input.rows(2..4)")),
|
||||
("C4", Some("Input.cell(3,2)")),
|
||||
("C4:D5", Some("Input.cell(3,2).resize(2,2)")),
|
||||
("B12:D12", Some("Input.below")),
|
||||
("C12", Some("Input.col(2).below")),
|
||||
("C12:C13", Some("Input.col(2).below(2)")),
|
||||
("E2:E11", Some("Input.right")),
|
||||
("E5", Some("Input.row(4).right")),
|
||||
("E3:F4", Some("Input.rows(2..3).right(2)")),
|
||||
("G2", Some("Input.cell(1,6)")),
|
||||
("A1", Some("Input.cell(0,0)")),
|
||||
("Z500", None),
|
||||
("A:A", None),
|
||||
] {
|
||||
assert_eq!(ref_token(t, ex, "Sheet1", true).as_deref(), want, "{t}");
|
||||
}
|
||||
assert_eq!(ref_token("Sheet1!C4", ex, "Sheet1", true).as_deref(), Some("Input.cell(3,2)"));
|
||||
assert_eq!(ref_token("Other!C4", ex, "Sheet1", true), None);
|
||||
// On another sheet only a reference qualified with the input's sheet is on it.
|
||||
assert_eq!(ref_token("C4", ex, "Sheet1", false), None);
|
||||
assert_eq!(ref_token("Sheet1!C4", ex, "Sheet1", false).as_deref(), Some("Input.cell(3,2)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formulas_get_tokens_for_exact_references() {
|
||||
let ex = r("B2:C5");
|
||||
for (f, want) in [
|
||||
("=SUM(C3:C5)", "=SUM({{Input.rows(2..).col(2)}})"),
|
||||
("=SUM(C2:C5)+COUNT(B2:C5)", "=SUM({{Input.col(2)}})+COUNT({{Input}})"),
|
||||
("=SUM($C$3:$C$5)", "=SUM($C$3:$C$5)"),
|
||||
("=SUM(Other!C3:C5)", "=SUM(Other!C3:C5)"),
|
||||
("=\"C3:C5\"&C3:C5", "=\"C3:C5\"&{{Input.rows(2..).col(2)}}"),
|
||||
("=SUM(AC3:C5)", "=SUM(AC3:C5)"),
|
||||
("=B2", "=B2"),
|
||||
] {
|
||||
assert_eq!(formula_tokens(f, ex).0, want, "{f}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,970 @@
|
||||
//! Macro tokens: references in step parameters that are worked out when the step runs, so a
|
||||
//! macro made on one block of data works on another.
|
||||
//!
|
||||
//! A token is a base followed by methods:
|
||||
//!
|
||||
//! - bases: a declared input (`Input`, `Rates`), `Output` (the output rule's anchor), `Row` or
|
||||
//! `Column` (the current item of the Repeat step around it), and `Name:Total` (a defined name or
|
||||
//! a table's data; a name can hold periods, and the longest one that exists is used, so
|
||||
//! `Name:Sales.Q1.cell(1,1)` is a cell of `Sales.Q1`);
|
||||
//! - methods, 1-based and chainable: `.col(n)` or `.col("Amount")` (by an input's header row,
|
||||
//! also after `.data`), `.row(n)`, `.cols(a..b)`, `.rows(a..)`, `.cell(r,c)` (relative to the
|
||||
//! top-left cell; any whole numbers, so `cell(0,1)` is the cell above), `.offset(r,c)`,
|
||||
//! `.resize(h,w)`, `.data` (without the header row), `.below(n)` and `.right(n)` (the n rows
|
||||
//! below or columns right of the range).
|
||||
//!
|
||||
//! In parameters a token is written three ways: `{"$ref": "Input.col(2)"}` anywhere; the whole
|
||||
//! string `"Input.col(2)"` in a reference parameter (`cell`, `range`, `target`, `to`, keys ending
|
||||
//! in `Cell` or `Range`…); or `{{Input.col(2)}}` inside any string (`"=SUM({{Input.col(2)}})"`).
|
||||
//! Each becomes A1 text before the command runs, so commands never see a token. Text that would
|
||||
//! read as a token is written `{"$text": "Output"}`: it is passed as it is, never resolved or
|
||||
//! interpolated (a defined name called `Output`, or `Dear {{name}}`). Plain A1
|
||||
//! references in steps are absolute: they don't follow row inserts (as in VBA); a `Name:` anchor
|
||||
//! does.
|
||||
|
||||
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef};
|
||||
use gridcraft_model::{RepeatOf, Workbook};
|
||||
use serde_json::{Map, Value as Json};
|
||||
|
||||
/// A resolved reference: a block of cells on a sheet.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Place {
|
||||
pub sheet: usize,
|
||||
pub range: RangeRef,
|
||||
}
|
||||
|
||||
/// Why a reference couldn't be worked out.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ResolveError {
|
||||
/// It runs past the edge of the sheet.
|
||||
Edge(String),
|
||||
/// Anything else (an unknown input, a malformed token).
|
||||
Bad(String),
|
||||
}
|
||||
|
||||
impl ResolveError {
|
||||
pub fn message(&self) -> &str {
|
||||
match self {
|
||||
ResolveError::Edge(m) | ResolveError::Bad(m) => m,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ResolveError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.message())
|
||||
}
|
||||
}
|
||||
|
||||
type R<T> = Result<T, ResolveError>;
|
||||
|
||||
fn bad<T>(msg: impl Into<String>) -> R<T> {
|
||||
Err(ResolveError::Bad(msg.into()))
|
||||
}
|
||||
|
||||
/// What tokens refer to while a step runs.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Env<'a> {
|
||||
pub wb: &'a Workbook,
|
||||
/// The sheet a reference without a sheet name is on.
|
||||
pub active_sheet: usize,
|
||||
/// Declared inputs, with whether each has a header row.
|
||||
pub inputs: &'a [(String, Place, bool)],
|
||||
pub output: Option<Place>,
|
||||
/// The innermost Repeat step's current row or column.
|
||||
pub item: Option<(RepeatOf, Place)>,
|
||||
}
|
||||
|
||||
/// Parameter keys that hold references, where a whole-string token is resolved. Elsewhere a
|
||||
/// string is text (a cell may hold "Row", a chart's type is "column"), and a token needs
|
||||
/// `{"$ref": …}` or `{{…}}`.
|
||||
const REF_KEYS: &[&str] = &[
|
||||
"active",
|
||||
"anchor",
|
||||
"area",
|
||||
"areas",
|
||||
"at",
|
||||
"cell",
|
||||
"cells",
|
||||
"changing",
|
||||
"copyTo",
|
||||
"destination",
|
||||
"from",
|
||||
"fromCell",
|
||||
"location",
|
||||
"objective",
|
||||
"over",
|
||||
"range",
|
||||
"ranges",
|
||||
"reference",
|
||||
"resultCells",
|
||||
"source",
|
||||
"sources",
|
||||
"target",
|
||||
"to",
|
||||
"variables",
|
||||
];
|
||||
|
||||
/// Whether a parameter called `key` holds a reference.
|
||||
pub fn is_ref_key(key: &str) -> bool {
|
||||
let k = key.to_ascii_lowercase();
|
||||
REF_KEYS.iter().any(|r| r.eq_ignore_ascii_case(key)) || ["cell", "cells", "range", "ranges", "ref"].iter().any(|end| k.ends_with(end))
|
||||
}
|
||||
|
||||
/// Reference keys that other commands use for plain words: `to` is a replacement word in
|
||||
/// Review › Change Spelling, `area` is `row` or `column` in a pivot field move. Under these a
|
||||
/// bare `Row` or `Column` stays text; `Row.col(2)`, `{{Row}}` or `{"$ref": "Row"}` is the token.
|
||||
fn is_general_key(key: &str) -> bool {
|
||||
["area", "areas", "at", "destination", "from", "location", "source", "sources", "target", "to"].iter().any(|k| k.eq_ignore_ascii_case(key))
|
||||
}
|
||||
|
||||
/// `Row` or `Column` on its own, with no method or sheet after it.
|
||||
fn is_bare_item_word(text: &str) -> bool {
|
||||
let t = text.trim();
|
||||
t.eq_ignore_ascii_case("row") || t.eq_ignore_ascii_case("column")
|
||||
}
|
||||
|
||||
/// The most nesting [`resolve_params`] walks into.
|
||||
const MAX_JSON_DEPTH: usize = 64;
|
||||
|
||||
/// Whether `text` starts like a token (its base is one this environment knows). A reference on a
|
||||
/// sheet with such a name (`Output!A1`) is not a token.
|
||||
pub fn is_token(text: &str, env: &Env) -> bool {
|
||||
starts_like_token(text, |base| env.inputs.iter().any(|(n, ..)| n.eq_ignore_ascii_case(base)))
|
||||
}
|
||||
|
||||
/// Whether `text` would read as a token in a macro whose inputs are `inputs` (see [`is_token`]).
|
||||
pub fn reads_as_token(text: &str, inputs: &[&str]) -> bool {
|
||||
starts_like_token(text, |base| inputs.iter().any(|n| n.eq_ignore_ascii_case(base)))
|
||||
}
|
||||
|
||||
fn starts_like_token(text: &str, is_input: impl Fn(&str) -> bool) -> bool {
|
||||
let t = text.trim();
|
||||
if t.get(..5).is_some_and(|h| h.eq_ignore_ascii_case("name:")) {
|
||||
return true;
|
||||
}
|
||||
let n: usize = t.chars().take_while(|c| c.is_alphanumeric() || *c == '_').map(char::len_utf8).sum();
|
||||
let base = t.get(..n).unwrap_or("");
|
||||
if t.get(n..).unwrap_or("").trim_start().starts_with('!') {
|
||||
return false;
|
||||
}
|
||||
is_builtin(base) || is_input(base)
|
||||
}
|
||||
|
||||
fn is_builtin(base: &str) -> bool {
|
||||
["Output", "Row", "Column"].iter().any(|b| b.eq_ignore_ascii_case(base))
|
||||
}
|
||||
|
||||
/// Resolves a token, or a plain reference (`B2`, `Sheet2!A1:C3`, a defined name).
|
||||
pub fn resolve_ref(text: &str, env: &Env) -> R<Place> {
|
||||
let t = text.trim();
|
||||
if is_token(t, env) {
|
||||
return resolve_token(t, env);
|
||||
}
|
||||
plain_ref(t, env)
|
||||
}
|
||||
|
||||
/// `A1`, `'My Sheet'!A1:B2` or a defined name or table.
|
||||
fn plain_ref(t: &str, env: &Env) -> R<Place> {
|
||||
let (sheet, body) = crate::cmd::split_sheet(t);
|
||||
let si = match &sheet {
|
||||
Some(name) => env.wb.sheet_index(name).ok_or_else(|| ResolveError::Bad(format!("There is no sheet named '{name}'.")))?,
|
||||
None => env.active_sheet,
|
||||
};
|
||||
if let Some(range) = RangeRef::parse(body) {
|
||||
return Ok(Place { sheet: si, range });
|
||||
}
|
||||
if sheet.is_none() {
|
||||
return named(t, env);
|
||||
}
|
||||
bad(format!("'{t}' isn't a reference."))
|
||||
}
|
||||
|
||||
/// A defined name (by its formula) or a table (its data rows).
|
||||
fn named(name: &str, env: &Env) -> R<Place> {
|
||||
if let Some(n) = env.wb.name(name, env.active_sheet) {
|
||||
// The sheet part keeps its characters (a sheet may be called `US$`); only the reference
|
||||
// loses its `$` anchors.
|
||||
let f = n.formula.trim().trim_start_matches('=');
|
||||
let (sheet, body) = crate::cmd::split_sheet(f);
|
||||
let si = match sheet {
|
||||
Some(s) => env.wb.sheet_index(&s),
|
||||
None => n.scope.or(Some(env.active_sheet)),
|
||||
};
|
||||
if let (Some(sheet), Some(range)) = (si, RangeRef::parse(&body.replace('$', ""))) {
|
||||
return Ok(Place { sheet, range });
|
||||
}
|
||||
return bad(format!("The name '{name}' doesn't refer to a range."));
|
||||
}
|
||||
if let Some((si, ti)) = env.wb.table(name)
|
||||
&& let Some(t) = env.wb.sheet(si).and_then(|sh| sh.tables.get(ti))
|
||||
{
|
||||
// A table stands for its data rows; one with none (a header, perhaps a totals row) has
|
||||
// nothing to give.
|
||||
let Some(range) = t.data_range() else {
|
||||
return bad(format!("The table '{name}' has no data rows."));
|
||||
};
|
||||
return Ok(Place { sheet: si, range });
|
||||
}
|
||||
bad(format!("There is no name or table called '{name}'."))
|
||||
}
|
||||
|
||||
/// Token method names.
|
||||
const METHODS: &[&str] = &["col", "row", "cols", "rows", "cell", "offset", "resize", "data", "below", "right"];
|
||||
|
||||
/// Where the name ends in `run`, the text after `Name:` up to the first character a name can't
|
||||
/// hold: at the end or at a period, the longest choice that is a defined name or table. When
|
||||
/// none is, the name ends before the first method (`Nope.cell`), so the error names it.
|
||||
fn name_end(run: &str, env: &Env) -> usize {
|
||||
// One pass over the names and tables, as a single lookup makes.
|
||||
let fits = |name: &str| {
|
||||
!name.is_empty()
|
||||
&& run.get(..name.len()).is_some_and(|head| head.eq_ignore_ascii_case(name))
|
||||
&& run.get(name.len()..).is_some_and(|rest| rest.is_empty() || rest.starts_with('.'))
|
||||
};
|
||||
let names = env.wb.names.iter().filter(|n| n.scope.is_none() || n.scope == Some(env.active_sheet)).map(|n| n.name.as_str());
|
||||
let tables = env.wb.sheets.iter().flat_map(|sh| sh.tables.iter().map(|t| t.name.as_str()));
|
||||
if let Some(end) = names.chain(tables).filter(|n| fits(n)).map(str::len).max() {
|
||||
return end;
|
||||
}
|
||||
run.match_indices('.')
|
||||
.map(|(i, _)| i)
|
||||
.find(|&i| {
|
||||
let next = run.get(i + 1..).unwrap_or("").split('.').next().unwrap_or("");
|
||||
METHODS.iter().any(|m| m.eq_ignore_ascii_case(next))
|
||||
})
|
||||
.unwrap_or(run.len())
|
||||
}
|
||||
|
||||
/// Resolves a token (see the module docs).
|
||||
pub fn resolve_token(text: &str, env: &Env) -> R<Place> {
|
||||
let t = text.trim();
|
||||
let mut p = Parser { s: t, at: 0 };
|
||||
let (mut place, mut header) = p.base(env)?;
|
||||
// The row of column names `col("…")` reads: the input's header row, kept through `data`.
|
||||
let mut names_row = header.then_some(place.range.start.row);
|
||||
while p.eat('.') {
|
||||
let name = p.ident();
|
||||
if name.is_empty() {
|
||||
return bad(format!("'{t}': expected a method name after '.'."));
|
||||
}
|
||||
let args = if p.eat('(') { p.args(t)? } else { vec![] };
|
||||
let r = place.range;
|
||||
let method = name.to_ascii_lowercase();
|
||||
let range = match (method.as_str(), args.as_slice()) {
|
||||
("col", [Arg::Int(n)]) => sub(r, None, Some((*n, *n)), t)?,
|
||||
("col", [Arg::Text(h)]) => {
|
||||
let Some(row) = names_row else {
|
||||
return bad(format!("'{t}': col(\"{h}\") needs an input with a header row; use col(n)."));
|
||||
};
|
||||
let c = header_col(env, place, row, h).ok_or_else(|| ResolveError::Bad(format!("'{t}': no column is headed '{h}'.")))?;
|
||||
sub(r, None, Some((c, c)), t)?
|
||||
}
|
||||
("row", [Arg::Int(n)]) => sub(r, Some((*n, *n)), None, t)?,
|
||||
("cols", [Arg::Span(a, b)]) => sub(r, None, Some(span(*a, *b, i64::from(r.width()))), t)?,
|
||||
("rows", [Arg::Span(a, b)]) => sub(r, Some(span(*a, *b, i64::from(r.height()))), None, t)?,
|
||||
("cell", [Arg::Int(row), Arg::Int(col)]) => {
|
||||
let c = shift(r.start, row - 1, col - 1, t)?;
|
||||
RangeRef::cell(c)
|
||||
}
|
||||
("offset", [Arg::Int(dr), Arg::Int(dc)]) => RangeRef::new(shift(r.start, *dr, *dc, t)?, shift(r.end, *dr, *dc, t)?),
|
||||
("resize", [Arg::Int(h), Arg::Int(w)]) => {
|
||||
if *h < 1 || *w < 1 {
|
||||
return bad(format!("'{t}': resize needs at least 1 row and 1 column."));
|
||||
}
|
||||
RangeRef::new(r.start, shift(r.start, h - 1, w - 1, t)?)
|
||||
}
|
||||
("data", []) => {
|
||||
if header {
|
||||
// A header row with nothing under it has no data rows to give.
|
||||
if r.height() < 2 {
|
||||
return bad(format!("'{t}': the range is only a header row, with no data under it."));
|
||||
}
|
||||
RangeRef::new(CellRef::new(r.start.row + 1, r.start.col), r.end)
|
||||
} else {
|
||||
r
|
||||
}
|
||||
}
|
||||
("below", []) | ("below", [Arg::Int(_)]) => {
|
||||
let n = count_arg(&args, t)?;
|
||||
let top = shift(CellRef::new(r.end.row, r.start.col), 1, 0, t)?;
|
||||
RangeRef::new(top, shift(CellRef::new(r.end.row, r.end.col), n, 0, t)?)
|
||||
}
|
||||
("right", []) | ("right", [Arg::Int(_)]) => {
|
||||
let n = count_arg(&args, t)?;
|
||||
let left = shift(CellRef::new(r.start.row, r.end.col), 0, 1, t)?;
|
||||
RangeRef::new(left, shift(r.end, 0, n, t)?)
|
||||
}
|
||||
_ => {
|
||||
return bad(format!("'{t}': '{name}' with these arguments isn't a token method ({}).", METHODS.join(", ")));
|
||||
}
|
||||
};
|
||||
// After a method the result is plain cells, except that `col` and `cols` keep the input's top
|
||||
// row, so a later `data` still takes the header off; only `data` keeps the header row for
|
||||
// `col("…")`.
|
||||
if !matches!(method.as_str(), "col" | "cols") {
|
||||
header = false;
|
||||
}
|
||||
if method != "data" {
|
||||
names_row = None;
|
||||
}
|
||||
place = Place { sheet: place.sheet, range };
|
||||
}
|
||||
p.skip_ws();
|
||||
if p.at < p.s.len() {
|
||||
return bad(format!("'{t}' isn't a token: unexpected '{}'.", p.s.get(p.at..).unwrap_or("")));
|
||||
}
|
||||
Ok(place)
|
||||
}
|
||||
|
||||
/// The count `below(n)` and `right(n)` take: 1 when left out, and never less than 1.
|
||||
fn count_arg(args: &[Arg], t: &str) -> R<i64> {
|
||||
match args.first() {
|
||||
Some(Arg::Int(n)) if *n < 1 => bad(format!("'{t}': the count must be at least 1.")),
|
||||
Some(Arg::Int(n)) => Ok(*n),
|
||||
_ => Ok(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// `a..b` with open ends filled from 1 and `len`.
|
||||
fn span(a: Option<i64>, b: Option<i64>, len: i64) -> (i64, i64) {
|
||||
(a.unwrap_or(1), b.unwrap_or(len))
|
||||
}
|
||||
|
||||
/// Rows `rows` and columns `cols` (1-based, inclusive) of `r`; `None` keeps them all.
|
||||
fn sub(r: RangeRef, rows: Option<(i64, i64)>, cols: Option<(i64, i64)>, t: &str) -> R<RangeRef> {
|
||||
let (h, w) = (i64::from(r.height()), i64::from(r.width()));
|
||||
let (r0, r1) = rows.unwrap_or((1, h));
|
||||
let (c0, c1) = cols.unwrap_or((1, w));
|
||||
if r0 < 1 || c0 < 1 || r1 < r0 || c1 < c0 || r1 > h || c1 > w {
|
||||
return bad(format!("'{t}' is outside the range it starts from ({} rows by {} columns).", h, w));
|
||||
}
|
||||
Ok(RangeRef::new(shift(r.start, r0 - 1, c0 - 1, t)?, shift(r.start, r1 - 1, c1 - 1, t)?))
|
||||
}
|
||||
|
||||
fn shift(c: CellRef, dr: i64, dc: i64, t: &str) -> R<CellRef> {
|
||||
let row = i64::from(c.row).checked_add(dr);
|
||||
let col = i64::from(c.col).checked_add(dc);
|
||||
match (row, col) {
|
||||
(Some(row), Some(col)) if (0..i64::from(MAX_ROWS)).contains(&row) && (0..i64::from(MAX_COLS)).contains(&col) => {
|
||||
Ok(CellRef::new(row as u32, col as u32))
|
||||
}
|
||||
(Some(row), Some(_)) if row < 0 => Err(ResolveError::Edge(format!("'{t}' runs past the top of the sheet."))),
|
||||
(Some(row), Some(_)) if row >= i64::from(MAX_ROWS) => Err(ResolveError::Edge(format!("'{t}' runs past the last row of the sheet."))),
|
||||
_ => Err(ResolveError::Edge(format!("'{t}' runs past the edge of the sheet."))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The 1-based column of `place` whose header (in sheet row `row`) reads `h`.
|
||||
fn header_col(env: &Env, place: Place, row: u32, h: &str) -> Option<i64> {
|
||||
let sh = env.wb.sheet(place.sheet)?;
|
||||
let r = place.range;
|
||||
(r.start.col..=r.end.col)
|
||||
// The header's value, however the sheet shows it: with Show Formulas on, the cell's
|
||||
// display text is its formula.
|
||||
.position(|c| crate::display::value_text(env.wb, sh, CellRef::new(row, c)).trim().eq_ignore_ascii_case(h.trim()))
|
||||
.map(|i| i as i64 + 1)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Arg {
|
||||
Int(i64),
|
||||
Span(Option<i64>, Option<i64>),
|
||||
Text(String),
|
||||
}
|
||||
|
||||
struct Parser<'a> {
|
||||
s: &'a str,
|
||||
at: usize,
|
||||
}
|
||||
|
||||
impl Parser<'_> {
|
||||
fn rest(&self) -> &str {
|
||||
self.s.get(self.at..).unwrap_or("")
|
||||
}
|
||||
fn skip_ws(&mut self) {
|
||||
let n = self.rest().len() - self.rest().trim_start().len();
|
||||
self.at += n;
|
||||
}
|
||||
fn eat(&mut self, c: char) -> bool {
|
||||
self.skip_ws();
|
||||
if self.rest().starts_with(c) {
|
||||
self.at += c.len_utf8();
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
fn ident(&mut self) -> String {
|
||||
self.skip_ws();
|
||||
let n: usize = self.rest().chars().take_while(|c| c.is_alphanumeric() || *c == '_').map(char::len_utf8).sum();
|
||||
let out = self.rest().get(..n).unwrap_or("").to_string();
|
||||
self.at += n;
|
||||
out
|
||||
}
|
||||
fn int(&mut self) -> Option<i64> {
|
||||
self.skip_ws();
|
||||
let neg = self.rest().starts_with('-');
|
||||
let start = self.at + usize::from(neg);
|
||||
let digits: usize = self.s.get(start..).unwrap_or("").chars().take_while(char::is_ascii_digit).count();
|
||||
if digits == 0 || digits > 9 {
|
||||
return None;
|
||||
}
|
||||
let n: i64 = self.s.get(start..start + digits)?.parse().ok()?;
|
||||
self.at = start + digits;
|
||||
Some(if neg { -n } else { n })
|
||||
}
|
||||
/// The base of a token and whether it has a header row.
|
||||
fn base(&mut self, env: &Env) -> R<(Place, bool)> {
|
||||
self.skip_ws();
|
||||
if self.rest().get(..5).is_some_and(|h| h.eq_ignore_ascii_case("name:")) {
|
||||
self.at += 5;
|
||||
// A name can hold periods (`Sales.Q1`), so it is the longest part of the run, up to
|
||||
// a period or the end, that names something; methods follow it.
|
||||
let n: usize = self.rest().chars().take_while(|c| c.is_alphanumeric() || matches!(c, '_' | '\\' | '.')).map(char::len_utf8).sum();
|
||||
let run = self.rest().get(..n).unwrap_or("").to_string();
|
||||
let end = name_end(&run, env);
|
||||
let name = run.get(..end).unwrap_or("");
|
||||
if name.is_empty() {
|
||||
return bad("'Name:' needs a defined name after it.");
|
||||
}
|
||||
self.at += end;
|
||||
return Ok((named(name, env)?, false));
|
||||
}
|
||||
let base = self.ident();
|
||||
if base.eq_ignore_ascii_case("Output") {
|
||||
return env.output.map(|p| (p, false)).ok_or_else(|| ResolveError::Bad("'Output' is used, but the macro has no output anchor.".into()));
|
||||
}
|
||||
if base.eq_ignore_ascii_case("Row") || base.eq_ignore_ascii_case("Column") {
|
||||
return match env.item {
|
||||
Some((_, p)) => Ok((p, false)),
|
||||
None => bad(format!("'{base}' only means something inside a Repeat step.")),
|
||||
};
|
||||
}
|
||||
match env.inputs.iter().find(|(n, ..)| n.eq_ignore_ascii_case(&base)) {
|
||||
Some((_, p, header)) => Ok((*p, *header)),
|
||||
None => bad(format!("'{base}' isn't an input of this macro.")),
|
||||
}
|
||||
}
|
||||
fn args(&mut self, t: &str) -> R<Vec<Arg>> {
|
||||
let mut out = Vec::new();
|
||||
if self.eat(')') {
|
||||
return Ok(out);
|
||||
}
|
||||
loop {
|
||||
self.skip_ws();
|
||||
if self.rest().starts_with('"') {
|
||||
self.at += 1;
|
||||
let n = self.rest().find('"').ok_or_else(|| ResolveError::Bad(format!("'{t}': a quote isn't closed.")))?;
|
||||
out.push(Arg::Text(self.rest().get(..n).unwrap_or("").to_string()));
|
||||
self.at += n + 1;
|
||||
} else {
|
||||
let a = self.int();
|
||||
self.skip_ws();
|
||||
if self.rest().starts_with("..") {
|
||||
self.at += 2;
|
||||
let b = self.int();
|
||||
out.push(Arg::Span(a, b));
|
||||
} else {
|
||||
out.push(Arg::Int(a.ok_or_else(|| ResolveError::Bad(format!("'{t}': expected a whole number.")))?));
|
||||
}
|
||||
}
|
||||
if out.len() > 4 {
|
||||
return bad(format!("'{t}': too many arguments."));
|
||||
}
|
||||
if self.eat(')') {
|
||||
return Ok(out);
|
||||
}
|
||||
if !self.eat(',') {
|
||||
return bad(format!("'{t}': expected ',' or ')'."));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `place` as A1 text, with the sheet name when it isn't the active sheet.
|
||||
pub fn a1(place: Place, env: &Env) -> String {
|
||||
if place.sheet == env.active_sheet {
|
||||
return place.range.a1();
|
||||
}
|
||||
let name = env.wb.sheet(place.sheet).map(|s| s.name.as_str()).unwrap_or("");
|
||||
format!("{}!{}", quote_sheet(name), place.range.a1())
|
||||
}
|
||||
|
||||
/// `Sheet1` or `'My Sheet'` (quotes doubled).
|
||||
pub fn quote_sheet(name: &str) -> String {
|
||||
// The engine's one rule: a name that reads as a cell reference, an R1C1 token or a boolean
|
||||
// is quoted too, or `Q1!A1` would be read as a reference to cell Q1.
|
||||
gridcraft_core::quote_sheet(name)
|
||||
}
|
||||
|
||||
/// Step parameters with every token replaced by A1 text.
|
||||
pub fn resolve_params(params: &Json, env: &Env) -> R<Json> {
|
||||
walk(params, None, env, 0)
|
||||
}
|
||||
|
||||
fn walk(v: &Json, key: Option<&str>, env: &Env, depth: usize) -> R<Json> {
|
||||
if depth > MAX_JSON_DEPTH {
|
||||
return bad("The step's parameters nest too deeply.");
|
||||
}
|
||||
Ok(match v {
|
||||
Json::Object(o) => {
|
||||
if o.len() == 1
|
||||
&& let Some(Json::String(t)) = o.get("$ref")
|
||||
{
|
||||
return Ok(Json::String(a1(resolve_ref(t, env)?, env)));
|
||||
}
|
||||
// Text passed as it is: never a token, and no `{{…}}` in it is read.
|
||||
if o.len() == 1
|
||||
&& let Some(Json::String(t)) = o.get("$text")
|
||||
{
|
||||
return Ok(Json::String(t.clone()));
|
||||
}
|
||||
let mut out = Map::new();
|
||||
for (k, x) in o {
|
||||
out.insert(k.clone(), walk(x, Some(k), env, depth + 1)?);
|
||||
}
|
||||
Json::Object(out)
|
||||
}
|
||||
// Items of a list keep their key's kind (`ranges: ["Input", …]`).
|
||||
Json::Array(a) => Json::Array(a.iter().map(|x| walk(x, key, env, depth + 1)).collect::<R<Vec<_>>>()?),
|
||||
Json::String(s) => {
|
||||
if key.is_some_and(|k| is_ref_key(k) && !(is_general_key(k) && is_bare_item_word(s))) && is_token(s, env) {
|
||||
Json::String(a1(resolve_token(s, env)?, env))
|
||||
} else {
|
||||
Json::String(interpolate(s, env)?)
|
||||
}
|
||||
}
|
||||
other => other.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Replaces each `{{token}}` in `s`. `{{{{` is a literal `{{`, for text that needs the braces
|
||||
/// themselves (`"Dear {{{{name}}"` writes `Dear {{name}}`).
|
||||
pub fn interpolate(s: &str, env: &Env) -> R<String> {
|
||||
if !s.contains("{{") {
|
||||
return Ok(s.to_string());
|
||||
}
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut rest = s;
|
||||
while let Some(i) = rest.find("{{") {
|
||||
out.push_str(rest.get(..i).unwrap_or(""));
|
||||
if rest.get(i..i + 4) == Some("{{{{") {
|
||||
out.push_str("{{");
|
||||
rest = rest.get(i + 4..).unwrap_or("");
|
||||
continue;
|
||||
}
|
||||
let after = rest.get(i + 2..).unwrap_or("");
|
||||
let Some(j) = after.find("}}") else {
|
||||
out.push_str(rest.get(i..).unwrap_or(""));
|
||||
return Ok(out);
|
||||
};
|
||||
let token = after.get(..j).unwrap_or("");
|
||||
out.push_str(&a1(resolve_ref(token, env)?, env));
|
||||
rest = after.get(j + 2..).unwrap_or("");
|
||||
}
|
||||
out.push_str(rest);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn wb() -> Workbook {
|
||||
let mut wb = Workbook::new();
|
||||
wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("My Data")));
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
for (i, h) in ["Name", "Amount", "Date"].iter().enumerate() {
|
||||
sh.set_value(CellRef::new(1, 1 + i as u32), gridcraft_core::Value::text(*h));
|
||||
}
|
||||
wb.names.push(gridcraft_model::DefinedName {
|
||||
name: "Total".into(),
|
||||
scope: None,
|
||||
formula: "Sheet1!$F$20".into(),
|
||||
comment: String::new(),
|
||||
hidden: false,
|
||||
});
|
||||
wb.names.push(gridcraft_model::DefinedName {
|
||||
name: "Rates".into(),
|
||||
scope: None,
|
||||
formula: "'My Data'!$A$1:$A$5".into(),
|
||||
comment: String::new(),
|
||||
hidden: false,
|
||||
});
|
||||
wb
|
||||
}
|
||||
|
||||
fn place(a1: &str) -> Place {
|
||||
Place { sheet: 0, range: RangeRef::parse(a1).unwrap() }
|
||||
}
|
||||
|
||||
fn check(env: &Env, token: &str, want: &str) {
|
||||
match resolve_ref(token, env) {
|
||||
Ok(p) => assert_eq!(a1(p, env), want, "{token}"),
|
||||
Err(e) => panic!("{token}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_formula_header_matches_while_formulas_are_shown() {
|
||||
// The header was read through the cell's display text, which is the formula while Show
|
||||
// Formulas is on, so `col("Amount")` found no column headed by a formula.
|
||||
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(), 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 };
|
||||
check(&env, "Input.col(\"Amount\")", "C2:C11");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_token_resolves() {
|
||||
let wb = wb();
|
||||
// Input B2:D11 with a header row; Other on another sheet.
|
||||
let inputs = vec![
|
||||
("Input".to_string(), place("B2:D11"), true),
|
||||
("Other".to_string(), Place { sheet: 1, range: RangeRef::parse("C3:C4").unwrap() }, false),
|
||||
];
|
||||
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: Some(place("G2")), item: Some((RepeatOf::Rows, place("B5:D5"))) };
|
||||
for (token, want) in [
|
||||
("Input", "B2:D11"),
|
||||
("input", "B2:D11"),
|
||||
("Input.col(2)", "C2:C11"),
|
||||
("Input.col(\"Amount\")", "C2:C11"),
|
||||
("Input.col(\"date\").data", "D3:D11"),
|
||||
("Input.cols(2..3).data", "C3:D11"),
|
||||
("Input.col(2).data.col(1)", "C3:C11"),
|
||||
("Input.data", "B3:D11"),
|
||||
("Input.data.col(2)", "C3:C11"),
|
||||
("Input.data.col(\"Amount\")", "C3:C11"),
|
||||
("Input.row(1)", "B2:D2"),
|
||||
("Input.rows(2..)", "B3:D11"),
|
||||
("Input.rows(2..4)", "B3:D5"),
|
||||
("Input.rows(..2)", "B2:D3"),
|
||||
("Input.cols(2..3)", "C2:D11"),
|
||||
("Input.cell(1,1)", "B2"),
|
||||
("Input.cell(3,2)", "C4"),
|
||||
("Input.cell(0,1)", "B1"),
|
||||
("Input.cell(12,3)", "D13"),
|
||||
("Input.offset(0,4)", "F2:H11"),
|
||||
("Input.offset(-1,-1)", "A1:C10"),
|
||||
("Input.cell(1,1).resize(2,3)", "B2:D3"),
|
||||
("Input.below()", "B12:D12"),
|
||||
("Input.below(2)", "B12:D13"),
|
||||
("Input.col(2).below", "C12"),
|
||||
("Input.right", "E2:E11"),
|
||||
(" Input . col( 2 ) ", "C2:C11"),
|
||||
("Output", "G2"),
|
||||
("Output.offset(1,0)", "G3"),
|
||||
("Row", "B5:D5"),
|
||||
("Row.cell(1,2)", "C5"),
|
||||
("Column", "B5:D5"),
|
||||
("Name:Total", "F20"),
|
||||
("Name:Rates", "'My Data'!A1:A5"),
|
||||
("Name:Rates.cell(2,1)", "'My Data'!A2"),
|
||||
("Other", "'My Data'!C3:C4"),
|
||||
("B7", "B7"),
|
||||
("'My Data'!A1:B2", "'My Data'!A1:B2"),
|
||||
("Total", "F20"),
|
||||
] {
|
||||
check(&env, token, want);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_tokens_say_why() {
|
||||
let wb = wb();
|
||||
// The same cells, read with and without a header row.
|
||||
let inputs = vec![("Input".to_string(), place("B2:D11"), false), ("Headed".to_string(), place("B2:D11"), true)];
|
||||
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None };
|
||||
for (token, needle, edge) in [
|
||||
("Input.col(4)", "outside the range", false),
|
||||
("Input.col(0)", "outside the range", false),
|
||||
("Input.rows(5..2)", "outside the range", false),
|
||||
("Headed.col(\"Nope\")", "no column is headed", false),
|
||||
("Input.col(\"Name\")", "needs an input with a header row", false),
|
||||
("Headed.col(2).col(\"Amount\")", "needs an input with a header row", false),
|
||||
("Name:Rates.col(\"Name\")", "needs an input with a header row", false),
|
||||
("Input.colour(1)", "isn't a token method", false),
|
||||
("Input.col(1", "expected", false),
|
||||
("Input.col(x)", "whole number", false),
|
||||
("Input.col(99999999999)", "whole number", false),
|
||||
("Input.", "method name", false),
|
||||
("Input junk", "unexpected", false),
|
||||
("Input.resize(0,1)", "at least 1 row", false),
|
||||
("Output", "no output anchor", false),
|
||||
("Row", "inside a Repeat", false),
|
||||
("Name:", "needs a defined name", false),
|
||||
("Name:Nothing", "no name or table", false),
|
||||
("Nowhere!A1", "no sheet named", false),
|
||||
("Input.offset(-2,0)", "past the top", true),
|
||||
("Input.offset(0,-2)", "edge of the sheet", true),
|
||||
("Input.offset(1048570,0)", "past the last row", true),
|
||||
("Input.cell(1,16384)", "edge of the sheet", true),
|
||||
] {
|
||||
let e = resolve_ref(token, &env).unwrap_err();
|
||||
assert!(e.message().contains(needle), "{token}: {e}");
|
||||
assert_eq!(matches!(e, ResolveError::Edge(_)), edge, "{token}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_resolve_tokens_in_reference_keys_and_braces_anywhere() {
|
||||
let wb = wb();
|
||||
let inputs = vec![("Input".to_string(), place("B2:D11"), true)];
|
||||
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: Some(place("F2")), item: None };
|
||||
let p = json!({
|
||||
"cell": "Output",
|
||||
"range": "Input.col(2)",
|
||||
"ranges": ["Input.row(1)", "A1"],
|
||||
"input": "Row",
|
||||
"value": "Input",
|
||||
"formula": "=SUM({{Input.data.col(2)}})/{{Name:Total}}",
|
||||
"nested": {"target": {"$ref": "Input.cell(1,1)"}, "n": 3, "flag": true},
|
||||
"text": {"$ref": "Output"},
|
||||
"type": "column",
|
||||
"sheet": "Input",
|
||||
"rowInputCell": "Input.cell(1,1)",
|
||||
"unclosed": "a {{b"
|
||||
});
|
||||
let r = resolve_params(&p, &env).unwrap();
|
||||
assert_eq!(
|
||||
r,
|
||||
json!({
|
||||
"cell": "F2",
|
||||
"range": "C2:C11",
|
||||
"ranges": ["B2:D2", "A1"],
|
||||
"input": "Row",
|
||||
"value": "Input",
|
||||
"formula": "=SUM(C3:C11)/F20",
|
||||
"nested": {"target": "B2", "n": 3, "flag": true},
|
||||
"text": "F2",
|
||||
"type": "column",
|
||||
"sheet": "Input",
|
||||
"rowInputCell": "B2",
|
||||
"unclosed": "a {{b"
|
||||
})
|
||||
);
|
||||
// Deeply nested parameters are refused, not followed.
|
||||
let mut deep = json!(1);
|
||||
for _ in 0..100 {
|
||||
deep = json!([deep]);
|
||||
}
|
||||
assert!(resolve_params(&deep, &env).is_err());
|
||||
assert!(resolve_params(&json!({"cell": "Input.col(9)"}), &env).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn references_on_sheets_named_like_tokens_stay_references() {
|
||||
// Sheets named after the built-in bases and after an input: `Output!A1` is a cell on the
|
||||
// sheet Output, in a reference key, in `$ref` and in `{{…}}`.
|
||||
let mut wb = Workbook::new();
|
||||
for name in ["Output", "Row", "Column", "Input", "Rates"] {
|
||||
wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new(name)));
|
||||
}
|
||||
let inputs = vec![("Input".to_string(), place("B2:D11"), true), ("Rates".to_string(), place("F2:F5"), false)];
|
||||
for output in [None, Some(place("H2"))] {
|
||||
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output, item: Some((RepeatOf::Rows, place("B5:D5"))) };
|
||||
for (sheet, i) in [("Output", 1), ("Row", 2), ("Column", 3), ("Input", 4), ("Rates", 5)] {
|
||||
for text in [format!("{sheet}!A1:B2"), format!("'{sheet}'!A1:B2")] {
|
||||
let want = Place { sheet: i, range: RangeRef::parse("A1:B2").unwrap() };
|
||||
assert_eq!(resolve_ref(&text, &env), Ok(want), "{text}");
|
||||
let a = a1(want, &env);
|
||||
let p = json!({"range": text, "target": {"$ref": text}, "formula": format!("=SUM({{{{{text}}}}})")});
|
||||
let r = resolve_params(&p, &env).unwrap();
|
||||
// A plain reference in a reference key is passed on as written.
|
||||
assert_eq!(r, json!({"range": text, "target": a, "formula": format!("=SUM({a})")}), "{text}");
|
||||
}
|
||||
}
|
||||
assert!(!is_token("Output !A1", &env));
|
||||
// The bare names are still tokens.
|
||||
check(&env, "Input.col(2)", "C2:C11");
|
||||
check(&env, "Rates", "F2:F5");
|
||||
check(&env, "Row", "B5:D5");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defined_names_can_hold_periods() {
|
||||
// `Sales.Q1` is one defined name, beside `Sales`; `Data.row` has a segment that reads like
|
||||
// a method. The longest name that exists is used, and methods follow it.
|
||||
let mut wb = wb();
|
||||
for (name, formula) in [("Sales", "Sheet1!$A$1"), ("Sales.Q1", "Sheet1!$B$2:$C$5"), ("Data.row", "'My Data'!$E$1:$E$3")] {
|
||||
wb.names.push(gridcraft_model::DefinedName {
|
||||
name: name.into(),
|
||||
scope: None,
|
||||
formula: formula.into(),
|
||||
comment: String::new(),
|
||||
hidden: false,
|
||||
});
|
||||
}
|
||||
let env = Env { wb: &wb, active_sheet: 0, inputs: &[], output: None, item: None };
|
||||
for (token, want) in [
|
||||
("Name:Sales.Q1", "B2:C5"),
|
||||
("Name:sales.q1", "B2:C5"),
|
||||
("Name:Sales.Q1.cell(1,1)", "B2"),
|
||||
("Name:Sales.Q1.col(2).row(4)", "C5"),
|
||||
("Name:Sales", "A1"),
|
||||
("Name:Sales.offset(1,0)", "A2"),
|
||||
("Name:Data.row", "'My Data'!E1:E3"),
|
||||
("Name:Data.row.row(2)", "'My Data'!E2"),
|
||||
("Name:Data.row.cell(1,1)", "'My Data'!E1"),
|
||||
("Sales.Q1", "B2:C5"),
|
||||
] {
|
||||
check(&env, token, want);
|
||||
}
|
||||
// In a reference parameter, `$ref` and `{{…}}`, with and without a method.
|
||||
let p = json!({
|
||||
"range": "Name:Sales.Q1",
|
||||
"cell": "Name:Sales.Q1.cell(1,1)",
|
||||
"a": {"$ref": "Name:Sales.Q1"},
|
||||
"b": {"$ref": "Name:Sales.Q1.cell(2,2)"},
|
||||
"formula": "=SUM({{Name:Sales.Q1}})+{{Name:Sales.Q1.cell(1,2)}}"
|
||||
});
|
||||
let r = resolve_params(&p, &env).unwrap();
|
||||
assert_eq!(r, json!({"range": "B2:C5", "cell": "B2", "a": "B2:C5", "b": "C3", "formula": "=SUM(B2:C5)+C2"}));
|
||||
// Unknown names and bad methods still fail, naming what is wrong.
|
||||
for (token, needle) in [
|
||||
("Name:Nope.Q1", "no name or table called 'Nope.Q1'"),
|
||||
("Name:Nope.cell(1,1)", "no name or table called 'Nope'"),
|
||||
("Name:Sales.Q1.bogus(1)", "'bogus' with these arguments isn't a token method"),
|
||||
("Name:Sales.Q1.cell(1", "expected"),
|
||||
("Name:Sales.Q1.", "method name"),
|
||||
("Name:.cell(1,1)", "needs a defined name"),
|
||||
] {
|
||||
let e = resolve_ref(token, &env).unwrap_err();
|
||||
assert!(e.message().contains(needle), "{token}: {e}");
|
||||
let e = resolve_params(&json!({"formula": format!("={{{{{token}}}}}")}), &env).unwrap_err();
|
||||
assert!(e.message().contains(needle), "{token} in braces: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sheet_names_are_quoted_when_needed() {
|
||||
// The engine's rule: a name that reads as a cell reference, an R1C1 token or a boolean
|
||||
// is quoted too, and a period on its own is not a reason to quote.
|
||||
for (name, want) in [
|
||||
("Sheet1", "Sheet1"),
|
||||
("My Data", "'My Data'"),
|
||||
("O'Hara", "'O''Hara'"),
|
||||
("2024", "'2024'"),
|
||||
("", "''"),
|
||||
("Q1", "'Q1'"),
|
||||
("R1C1", "'R1C1'"),
|
||||
("TRUE", "'TRUE'"),
|
||||
("Sales.Q1", "Sales.Q1"),
|
||||
] {
|
||||
assert_eq!(quote_sheet(name), want, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_row_or_column_under_a_general_key_is_text() {
|
||||
// `to` is a replacement word in Change Spelling and `area` is `row` or `column` in a
|
||||
// pivot field move, so a bare `Row` or `Column` there stays text, inside a Repeat or not;
|
||||
// the token forms still resolve there, and the bare word resolves under `range`.
|
||||
let wb = wb();
|
||||
let inputs = vec![("Input".to_string(), place("B2:D11"), true)];
|
||||
for item in [None, Some((RepeatOf::Rows, place("B5:D5")))] {
|
||||
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: Some(place("F2")), item };
|
||||
let p = json!({"word": "Colum", "to": "Column", "area": "row", "from": "Input", "at": "Output"});
|
||||
let r = resolve_params(&p, &env).unwrap();
|
||||
assert_eq!(r, json!({"word": "Colum", "to": "Column", "area": "row", "from": "B2:D11", "at": "F2"}), "{item:?}");
|
||||
}
|
||||
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: Some((RepeatOf::Rows, place("B5:D5"))) };
|
||||
let p = json!({"to": "Row.col(2)", "range": "Row", "area": {"$ref": "Row"}, "text": "{{Row}}"});
|
||||
let r = resolve_params(&p, &env).unwrap();
|
||||
assert_eq!(r, json!({"to": "C5", "range": "B5:D5", "area": "B5:D5", "text": "B5:D5"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_header_row_alone_and_a_table_without_data_rows_are_refused() {
|
||||
let mut wb = wb();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
sh.tables.push(gridcraft_model::Table {
|
||||
id: 1,
|
||||
name: "Empty".into(),
|
||||
range: RangeRef::parse("H1:J1").unwrap(),
|
||||
header_row: true,
|
||||
totals_row: false,
|
||||
columns: Vec::new(),
|
||||
style: String::new(),
|
||||
banded_rows: false,
|
||||
banded_cols: false,
|
||||
first_col: false,
|
||||
last_col: false,
|
||||
filter_button: false,
|
||||
filter: Vec::new(),
|
||||
table_type: None,
|
||||
connection_id: None,
|
||||
});
|
||||
let inputs = vec![("Input".to_string(), place("B2:D2"), true), ("Plain".to_string(), place("B2:D2"), false)];
|
||||
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None };
|
||||
assert!(resolve_ref("Input.data", &env).unwrap_err().to_string().contains("only a header row"));
|
||||
assert!(resolve_ref("Input.data.col(2)", &env).unwrap_err().to_string().contains("only a header row"));
|
||||
check(&env, "Plain.data", "B2:D2");
|
||||
check(&env, "Input.col(2)", "C2");
|
||||
assert!(resolve_ref("Name:Empty", &env).unwrap_err().to_string().contains("no data rows"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doubled_braces_are_literal_braces() {
|
||||
let wb = wb();
|
||||
let inputs = vec![("Input".to_string(), place("B2:D11"), true)];
|
||||
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None };
|
||||
assert_eq!(interpolate("Dear {{{{name}},", &env).unwrap(), "Dear {{name}},");
|
||||
assert_eq!(interpolate("{{{{ {{Input.cell(1,1)}} }}", &env).unwrap(), "{{ B2 }}");
|
||||
assert!(interpolate("Dear {{name}},", &env).is_err(), "an unknown token is still an error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_marked_as_text_is_never_a_token() {
|
||||
// A defined name called Output or Input in a reference parameter, a Name: prefix and
|
||||
// braces: `$text` passes each as it is, in any key; unmarked, each reads as a token.
|
||||
let wb = wb();
|
||||
let inputs = vec![("Input".to_string(), place("B2:D11"), true)];
|
||||
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None };
|
||||
for text in ["Output", "Input", "Name:Nowhere", "Row", "Dear {{name}}"] {
|
||||
let p = json!({"range": {"$text": text}, "input": {"$text": text}});
|
||||
assert_eq!(resolve_params(&p, &env).unwrap(), json!({"range": text, "input": text}), "{text}");
|
||||
assert_ne!(resolve_params(&json!({"range": text}), &env).ok(), Some(json!({"range": text})), "{text}");
|
||||
assert!(reads_as_token(text, &["Input"]) || text.contains("{{"), "{text}");
|
||||
}
|
||||
assert!(!reads_as_token("Output!A1", &["Input"]) && !reads_as_token("B2", &["Input"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_defined_name_on_a_sheet_with_a_dollar_sign_resolves() {
|
||||
let mut wb = wb();
|
||||
wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("US$")));
|
||||
wb.names.push(gridcraft_model::DefinedName {
|
||||
name: "Dollars".into(),
|
||||
scope: None,
|
||||
formula: "'US$'!$F$20".into(),
|
||||
comment: String::new(),
|
||||
hidden: false,
|
||||
});
|
||||
let env = Env { wb: &wb, active_sheet: 0, inputs: &[], output: None, item: None };
|
||||
check(&env, "Name:Dollars", "'US$'!F20");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn below_and_right_refuse_a_count_under_one() {
|
||||
let wb = wb();
|
||||
let inputs = vec![("Input".to_string(), place("B2:D11"), true)];
|
||||
let env = Env { wb: &wb, active_sheet: 0, inputs: &inputs, output: None, item: None };
|
||||
check(&env, "Input.below", "B12:D12");
|
||||
check(&env, "Input.below(2)", "B12:D13");
|
||||
check(&env, "Input.right(1)", "E2:E11");
|
||||
for bad in ["Input.below(0)", "Input.below(-3)", "Input.right(0)"] {
|
||||
assert!(resolve_ref(bad, &env).unwrap_err().to_string().contains("at least 1"), "{bad}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user