mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 10:46:50 -06:00
Compare commits
743
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ee8c0d15f | ||
|
|
33a45130a1 | ||
|
|
a7608ada6d | ||
|
|
bdf8b8cc75 | ||
|
|
168acc5889 | ||
|
|
f1e29fa828 | ||
|
|
fe2f9a7582 | ||
|
|
a09454e250 | ||
|
|
bcf55f0f00 | ||
|
|
4b8725b29b | ||
|
|
e92d8b4e48 | ||
|
|
817cf80470 | ||
|
|
2d0fe1f01e | ||
|
|
d1399ff2ca | ||
|
|
67f13d804c | ||
|
|
50e5a3e928 | ||
|
|
a725f2dc67 | ||
|
|
27a33f1ee0 | ||
|
|
5d0fc0b06a | ||
|
|
3c62ae27fd | ||
|
|
073ae2df39 | ||
|
|
8eda577545 | ||
|
|
7c1ec1df7d | ||
|
|
60776ff4de | ||
|
|
d19f656014 | ||
|
|
6febf5442f | ||
|
|
d046ba6aa7 | ||
|
|
ac8da7d49c | ||
|
|
10fb7d566e | ||
|
|
73af8c6a6d | ||
|
|
09f78cb3a8 | ||
|
|
a8e2ed31b1 | ||
|
|
bc0cd89225 | ||
|
|
524ce912e5 | ||
|
|
ebc986fcc9 | ||
|
|
9cbb674c55 | ||
|
|
d1db71d0f0 | ||
|
|
b5b3969377 | ||
|
|
6dedb9310b | ||
|
|
74f29a743f | ||
|
|
e6ffca60bb | ||
|
|
5bcbaacce7 | ||
|
|
840ba5c3a5 | ||
|
|
99f92f4a28 | ||
|
|
843c9b8404 | ||
|
|
56207f2d3c | ||
|
|
a1c84547e3 | ||
|
|
7ed494691c | ||
|
|
8307d3cb05 | ||
|
|
c39d2ad197 | ||
|
|
742a6e8535 | ||
|
|
e177bf1c44 | ||
|
|
b9801f3f81 | ||
|
|
06920de8da | ||
|
|
6f10378b17 | ||
|
|
c4c7f747c7 | ||
|
|
818175f51e | ||
|
|
c7016fb724 | ||
|
|
cde364f269 | ||
|
|
d5cdd2ea2b | ||
|
|
561382cc6d | ||
|
|
ca1cdd4229 | ||
|
|
c7fd66d807 | ||
|
|
9350ab1def | ||
|
|
4c5c345c31 | ||
|
|
6dda6be2c6 | ||
|
|
d3201e7cea | ||
|
|
a680ac59f0 | ||
|
|
cfda01c831 | ||
|
|
4c81d658ec | ||
|
|
512b2aa9fd | ||
|
|
b0b6409112 | ||
|
|
54d0f7cfc0 | ||
|
|
f193f0d834 | ||
|
|
d056570d97 | ||
|
|
21c3ec248c | ||
|
|
ec61d59b41 | ||
|
|
55aeed6f1a | ||
|
|
1b4b51225d | ||
|
|
b35b5a8b9d | ||
|
|
7b47c5a846 | ||
|
|
55877df80f | ||
|
|
f02f446905 | ||
|
|
4ac3f2fa64 | ||
|
|
1d9b29ff57 | ||
|
|
c37298b468 | ||
|
|
36c9df453f | ||
|
|
47bf083d0a | ||
|
|
a1c1fc05de | ||
|
|
d4eabcee96 | ||
|
|
10ac46c243 | ||
|
|
0eeaeec0c1 | ||
|
|
e72d5f290a | ||
|
|
905ef6a6a5 | ||
|
|
4286ac2930 | ||
|
|
7649a762ff | ||
|
|
e4c406dbcf | ||
|
|
8bfe310208 | ||
|
|
a9a8fe7d20 | ||
|
|
3a01ce689e | ||
|
|
9d3252189d | ||
|
|
1316d39865 | ||
|
|
56c31b0b31 | ||
|
|
169c78dda8 | ||
|
|
d8b73ecb4e | ||
|
|
f5b35fb3ac | ||
|
|
470a2ae2c1 | ||
|
|
ad1899e1ca | ||
|
|
01485e2b66 | ||
|
|
37bf37d181 | ||
|
|
f4f18bb368 | ||
|
|
31d6221463 | ||
|
|
59e6f123ba | ||
|
|
ad7fe4ed70 | ||
|
|
857bdae023 | ||
|
|
46ddd61021 | ||
|
|
48285a8fc4 | ||
|
|
e738049157 | ||
|
|
75f6c92eab | ||
|
|
6432253de9 | ||
|
|
cd3fcfd2a0 | ||
|
|
c9941763cc | ||
|
|
d44c08c354 | ||
|
|
6370fd2254 | ||
|
|
e6ccfba926 | ||
|
|
fccca928b3 | ||
|
|
431aaaaf69 | ||
|
|
70426fc913 | ||
|
|
8ec29a8b3b | ||
|
|
d97f61748e | ||
|
|
8e2974cc65 | ||
|
|
42768628c4 | ||
|
|
826a22dc2b | ||
|
|
cba89cfda8 | ||
|
|
90d4c3af0f | ||
|
|
608925af08 | ||
|
|
ced3f7ce7a | ||
|
|
7fd1553a8b | ||
|
|
a8f74730c1 | ||
|
|
929c506d1b | ||
|
|
8590dca0db | ||
|
|
7221e11f56 | ||
|
|
d0394a0f34 | ||
|
|
d67193f403 | ||
|
|
75e84a26d5 | ||
|
|
823914157a | ||
|
|
07f806d3e5 | ||
|
|
635056bd23 | ||
|
|
edb214b236 | ||
|
|
78b7ef49fd | ||
|
|
7963cdb218 | ||
|
|
7269b12f1e | ||
|
|
c03d4a881b | ||
|
|
7e74b35a1b | ||
|
|
66e6c9f54e | ||
|
|
508d230f7f | ||
|
|
5d0d1c2b95 | ||
|
|
05ecd6d4a8 | ||
|
|
2676c0894b | ||
|
|
8de6c8376a | ||
|
|
7768f56603 | ||
|
|
7c94f9c9bb | ||
|
|
6b0411091a | ||
|
|
d20fe88b98 | ||
|
|
9c2cc2bff4 | ||
|
|
ee908a2124 | ||
|
|
2fbb45eac0 | ||
|
|
6f62e74704 | ||
|
|
881b3f5fe2 | ||
|
|
eaf9a3f230 | ||
|
|
524ab89a1a | ||
|
|
6e66756ce8 | ||
|
|
ff546d8ffb | ||
|
|
44320b393e | ||
|
|
c8a20cfaf6 | ||
|
|
0c45bd46b6 | ||
|
|
05e4b5e3d8 | ||
|
|
7a016c2de9 | ||
|
|
46647fd61e | ||
|
|
013ead7389 | ||
|
|
2aec7f2d5a | ||
|
|
aab44d2c23 | ||
|
|
5c1b171892 | ||
|
|
3ed16929d9 | ||
|
|
2aaa09119e | ||
|
|
e85afa6fe5 | ||
|
|
d83dc11ff2 | ||
|
|
6d4e62d12b | ||
|
|
4e74bb762b | ||
|
|
994b2e66c9 | ||
|
|
4fd4d39aa0 | ||
|
|
26c6e4ca14 | ||
|
|
0118413c77 | ||
|
|
b17f47b2ca | ||
|
|
cede13d0a7 | ||
|
|
5f4309e4ba | ||
|
|
66c64a9331 | ||
|
|
c31e25b39d | ||
|
|
e49b2d8830 | ||
|
|
c29cac503f | ||
|
|
0e298839aa | ||
|
|
a2314b5414 | ||
|
|
77b861392a | ||
|
|
c5007d5589 | ||
|
|
faef39cccc | ||
|
|
41bf1f0e52 | ||
|
|
8991b7d090 | ||
|
|
3af424f7ac | ||
|
|
195bd22a9a | ||
|
|
b4980ebd32 | ||
|
|
10746f8e33 | ||
|
|
d691e95f6d | ||
|
|
c6fb01ff52 | ||
|
|
4a8765aa27 | ||
|
|
f8c9f87f6e | ||
|
|
b40c0a6d05 | ||
|
|
87df1947f9 | ||
|
|
a9e20d81e2 | ||
|
|
d5e2b2fc77 | ||
|
|
d26bbe3432 | ||
|
|
7da18f74ba | ||
|
|
cc76df4024 | ||
|
|
b388aaa241 | ||
|
|
1aa5d4ea79 | ||
|
|
2023b96434 | ||
|
|
f68f1e48a1 | ||
|
|
8709b41666 | ||
|
|
33b6dfd44e | ||
|
|
bdb03349f6 | ||
|
|
0bf7d40f3b | ||
|
|
b10e41c1c7 | ||
|
|
9d1f5503c9 | ||
|
|
43cd35d4bd | ||
|
|
39add6b7c3 | ||
|
|
69f6ea363d | ||
|
|
5186b9c964 | ||
|
|
4c3dc617cc | ||
|
|
8daa1f41e1 | ||
|
|
cd5d21740a | ||
|
|
cfd3e789ac | ||
|
|
0592dd0a5e | ||
|
|
0e6b31ab71 | ||
|
|
7116252960 | ||
|
|
8daecc7777 | ||
|
|
9e56ed3803 | ||
|
|
4cdffe455f | ||
|
|
2c10a8da46 | ||
|
|
15dfbef548 | ||
|
|
cacb91d29d | ||
|
|
708c82cf2c | ||
|
|
c8e0323cf3 | ||
|
|
56cccd1f64 | ||
|
|
93517ccf51 | ||
|
|
1f9696d013 | ||
|
|
3ad6034f33 | ||
|
|
ad490a1393 | ||
|
|
cbf13a4c1d | ||
|
|
f8b7547344 | ||
|
|
62c70242ce | ||
|
|
51401a382c | ||
|
|
6f8b6c33e0 | ||
|
|
36763fb663 | ||
|
|
8fa5035b21 | ||
|
|
013f60a8b6 | ||
|
|
32faa4f1c6 | ||
|
|
ee54646258 | ||
|
|
8d1263a047 | ||
|
|
0f27cef4d6 | ||
|
|
6139ef7537 | ||
|
|
6735e9e7c0 | ||
|
|
3eda63d5e5 | ||
|
|
604d85e29c | ||
|
|
9f03d2cd1b | ||
|
|
040d778182 | ||
|
|
ccbf28fe63 | ||
|
|
e9479d2155 | ||
|
|
675d96ef17 | ||
|
|
5276fce027 | ||
|
|
0cc4798ca9 | ||
|
|
5b2a67c236 | ||
|
|
696555f484 | ||
|
|
0087afce8a | ||
|
|
240e773e17 | ||
|
|
38fb979498 | ||
|
|
93de710d28 | ||
|
|
78225db6f8 | ||
|
|
0b573362bc | ||
|
|
02640d6626 | ||
|
|
02e1828c65 | ||
|
|
7045146342 | ||
|
|
90cdb12fc6 | ||
|
|
ee6dead310 | ||
|
|
49328f80c0 | ||
|
|
0d741d9756 | ||
|
|
9a22d1e628 | ||
|
|
a512a6a8a7 | ||
|
|
2a3ea02e69 | ||
|
|
806a439efa | ||
|
|
8319587046 | ||
|
|
4f7111941e | ||
|
|
116cc8697a | ||
|
|
00fd1e7a6f | ||
|
|
011ae7266d | ||
|
|
4fd7657508 | ||
|
|
941da33355 | ||
|
|
522b8a1f42 | ||
|
|
d34e3db0b8 | ||
|
|
e51bc1ab70 | ||
|
|
8e931b24b4 | ||
|
|
7f9cf33bfd | ||
|
|
a3baf552a1 | ||
|
|
91af0c60d6 | ||
|
|
7bf0c8dbd7 | ||
|
|
4ec2de2501 | ||
|
|
2cbd39ca8d | ||
|
|
2c73c2b18f | ||
|
|
1f1be405c1 | ||
|
|
4434e142fb | ||
|
|
ccc0e8cfe4 | ||
|
|
c4389fcacb | ||
|
|
cedaa46916 | ||
|
|
947930e889 | ||
|
|
c470450eb3 | ||
|
|
ed4b4e1ca7 | ||
|
|
0e077cfd88 | ||
|
|
f55cbcd061 | ||
|
|
bd4c875225 | ||
|
|
8a7f0348b6 | ||
|
|
8041fbfc22 | ||
|
|
91be133965 | ||
|
|
34de196c9f | ||
|
|
a0a4b6a7d0 | ||
|
|
2c22070a62 | ||
|
|
307b25dc92 | ||
|
|
a48f418a77 | ||
|
|
df39416e36 | ||
|
|
340ce131ef | ||
|
|
136a8fa07e | ||
|
|
ec5e939677 | ||
|
|
a4c53ad020 | ||
|
|
954f30f9f4 | ||
|
|
f8f7193f52 | ||
|
|
0288ad2f31 | ||
|
|
9aed6fb534 | ||
|
|
f295fa0947 | ||
|
|
06b7d09a7e | ||
|
|
5392d95bf8 | ||
|
|
b817c04a9b | ||
|
|
836b269806 | ||
|
|
5b908c04f8 | ||
|
|
be94b011c5 | ||
|
|
e1b8aad143 | ||
|
|
912b817abe | ||
|
|
c3184b74f9 | ||
|
|
8ae67c5534 | ||
|
|
eb48beffb5 | ||
|
|
a9223eabf8 | ||
|
|
7ba381d9ae | ||
|
|
0812d3aee5 | ||
|
|
2ebdf164c2 | ||
|
|
b092633561 | ||
|
|
f91f7c05d4 | ||
|
|
97aa04dc80 | ||
|
|
e42ebf9311 | ||
|
|
377bb9226e | ||
|
|
c812881fa9 | ||
|
|
c26290cd04 | ||
|
|
43e64c50a2 | ||
|
|
8e38fc21fa | ||
|
|
028a02ba72 | ||
|
|
29adb19872 | ||
|
|
37e41424ed | ||
|
|
a35d241caf | ||
|
|
d1ab0ea85d | ||
|
|
a4a36e8460 | ||
|
|
1abb771f58 | ||
|
|
68f956a410 | ||
|
|
bdc0552a92 | ||
|
|
cacb620fec | ||
|
|
226c055fb7 | ||
|
|
1788c5a771 | ||
|
|
83a41dcd68 | ||
|
|
41cb19fd6d | ||
|
|
eb30ab8f3f | ||
|
|
feac1679a1 | ||
|
|
f29ac7d9ef | ||
|
|
c8a87f57dd | ||
|
|
45c91ede21 | ||
|
|
60e113bcd7 | ||
|
|
68e58a2eae | ||
|
|
ed48389c46 | ||
|
|
ad0ce61b8a | ||
|
|
24ee1e4db7 | ||
|
|
bc58b2773a | ||
|
|
2fb880a6d5 | ||
|
|
4faa3ff098 | ||
|
|
4e17be1929 | ||
|
|
cfb0e186ed | ||
|
|
189eb7ae52 | ||
|
|
132e887efa | ||
|
|
ad32e6a847 | ||
|
|
40803e6aca | ||
|
|
2c6ffe489c | ||
|
|
8ff06e215e | ||
|
|
fc1d629cc5 | ||
|
|
27760812de | ||
|
|
a2cc4b42ca | ||
|
|
479d1b4b66 | ||
|
|
199e5f8e05 | ||
|
|
edd6e0cbe8 | ||
|
|
4ce8dc55ec | ||
|
|
52ab0e6bfb | ||
|
|
b3e0a7e8d3 | ||
|
|
2aeb21d4d3 | ||
|
|
d9d4d64645 | ||
|
|
23829d8c67 | ||
|
|
0ca1554642 | ||
|
|
0b4cc5618f | ||
|
|
026239458a | ||
|
|
a74e82ac93 | ||
|
|
122d3d9a31 | ||
|
|
553b0bd9f2 | ||
|
|
871c4e6c8f | ||
|
|
e9bcb8dfbb | ||
|
|
9f13434972 | ||
|
|
ac371be50d | ||
|
|
e34b0aa345 | ||
|
|
37f7b43bca | ||
|
|
e8b3279d9f | ||
|
|
ef339b9f2c | ||
|
|
13e730d42a | ||
|
|
4445aa6ddf | ||
|
|
3979e48f89 | ||
|
|
e5aeea7a94 | ||
|
|
aff9153dac | ||
|
|
43655df887 | ||
|
|
da97bde61c | ||
|
|
6760e006ec | ||
|
|
24b0b5d167 | ||
|
|
4faca49836 | ||
|
|
6481c17150 | ||
|
|
f84557b4c6 | ||
|
|
d19627fc26 | ||
|
|
f8cec7dd6d | ||
|
|
5e0b2dd45c | ||
|
|
3df5e05f0f | ||
|
|
49fe41afbf | ||
|
|
4401b3e5c5 | ||
|
|
aa4125952e | ||
|
|
8492416c74 | ||
|
|
47fc3d5d35 | ||
|
|
70018d4e60 | ||
|
|
b402359fa4 | ||
|
|
3a2232cfef | ||
|
|
92c0d1e936 | ||
|
|
27f4c9c7bc | ||
|
|
f5267cb74e | ||
|
|
b77848ee21 | ||
|
|
f8e916bcd4 | ||
|
|
ab6fd48856 | ||
|
|
06a94d42b2 | ||
|
|
7b8aaf9ca7 | ||
|
|
12ee27a177 | ||
|
|
8d562e9b56 | ||
|
|
937cfd1e91 | ||
|
|
46c706a491 | ||
|
|
33ac9e5f64 | ||
|
|
8968e83359 | ||
|
|
f2473d1f33 | ||
|
|
a6757829eb | ||
|
|
29f9959521 | ||
|
|
8e8e2ba73c | ||
|
|
33a5415f0e | ||
|
|
859e654dce | ||
|
|
93cb9828bd | ||
|
|
851583365f | ||
|
|
448c167e68 | ||
|
|
9767efe66e | ||
|
|
752477a231 | ||
|
|
ea00ca6b1f | ||
|
|
71acb0c50b | ||
|
|
5c4dbbbc4a | ||
|
|
8f239e3b2f | ||
|
|
d890a1a7da | ||
|
|
92e1e96997 | ||
|
|
5eae222fbc | ||
|
|
02ceedee17 | ||
|
|
31abf075b7 | ||
|
|
7cd0a386aa | ||
|
|
eee872e429 | ||
|
|
e9ffc71c6a | ||
|
|
041bee2168 | ||
|
|
a10ead1c09 | ||
|
|
59074fdc16 | ||
|
|
d312535c40 | ||
|
|
90344445d7 | ||
|
|
fb7b23dbad | ||
|
|
7776cfcc5c | ||
|
|
620be76873 | ||
|
|
6ac8ad9216 | ||
|
|
28cdf1d187 | ||
|
|
62e52004f8 | ||
|
|
5bafacff69 | ||
|
|
86641d965b | ||
|
|
cc21643b83 | ||
|
|
adcb460a63 | ||
|
|
9a79dbef47 | ||
|
|
0e8e7e34e4 | ||
|
|
5f748d5594 | ||
|
|
dcd4d1d13f | ||
|
|
4db6876c07 | ||
|
|
90e89d1b9f | ||
|
|
4ed93c3b2c | ||
|
|
f1015e1e6b | ||
|
|
66ce6be474 | ||
|
|
5f2fb705b8 | ||
|
|
383bb7ba59 | ||
|
|
6e984bb84d | ||
|
|
4f3853d4a9 | ||
|
|
9b8fde71ee | ||
|
|
d4f256d6ee | ||
|
|
95f8206bb9 | ||
|
|
beade8b734 | ||
|
|
dd5c7da6fc | ||
|
|
6082355c22 | ||
|
|
ede2b435d5 | ||
|
|
ef0b243cd3 | ||
|
|
e60a74e9cf | ||
|
|
53e5227085 | ||
|
|
c30fa3f1cf | ||
|
|
c5a53a90ac | ||
|
|
19f4387cca | ||
|
|
83189c0ccd | ||
|
|
217a8907ff | ||
|
|
823c56582d | ||
|
|
8ffff19dda | ||
|
|
0fe4cd840c | ||
|
|
c04c85579a | ||
|
|
fac8f858cb | ||
|
|
2321afd677 | ||
|
|
963219200c | ||
|
|
3f8167719a | ||
|
|
39c9d7cd63 | ||
|
|
71c1097d5a | ||
|
|
a6aa53cbff | ||
|
|
8ca66c5c90 | ||
|
|
edcc76d0a8 | ||
|
|
5627d2b6bf | ||
|
|
9e5cdf9e43 | ||
|
|
2a9f40c65e | ||
|
|
36e45a5c58 | ||
|
|
5539722557 | ||
|
|
94af08ad20 | ||
|
|
70fc5e1c78 | ||
|
|
a28799d02b | ||
|
|
ea3ef8faf7 | ||
|
|
8e7438b434 | ||
|
|
e8f049943f | ||
|
|
27932a2ae4 | ||
|
|
6adb26c719 | ||
|
|
84ce99f9c7 | ||
|
|
ec930b2fc8 | ||
|
|
042ac7060f | ||
|
|
4ca967e114 | ||
|
|
14f2ea9f57 | ||
|
|
295f7dde33 | ||
|
|
86b124fca6 | ||
|
|
1131de97ff | ||
|
|
4548c6f7fc | ||
|
|
3684349d9c | ||
|
|
a09d3f6934 | ||
|
|
c5baa829e3 | ||
|
|
efc8171609 | ||
|
|
214a67d53c | ||
|
|
3379fb63a5 | ||
|
|
0033dfcdeb | ||
|
|
ad4422f5f6 | ||
|
|
125c8b00e8 | ||
|
|
f1894c6da6 | ||
|
|
ac8bbd84eb | ||
|
|
e8c6fcb8b0 | ||
|
|
f3398ee218 | ||
|
|
706127402a | ||
|
|
e91e7f7a68 | ||
|
|
bb685e3275 | ||
|
|
81422c315d | ||
|
|
79e8973c30 | ||
|
|
fda8e7b703 | ||
|
|
d50e32bf25 | ||
|
|
1ab9e60d88 | ||
|
|
9f6bd5848c | ||
|
|
47185eef1f | ||
|
|
82ee02aac4 | ||
|
|
79e85c1b80 | ||
|
|
7a352cee09 | ||
|
|
c0d6f290e7 | ||
|
|
2bb222aea6 | ||
|
|
c6be911b99 | ||
|
|
883a07fb7b | ||
|
|
27dab49e3d | ||
|
|
98f4b9d855 | ||
|
|
0ec27a6101 | ||
|
|
f8cbfb7cb6 | ||
|
|
e6c0f92d59 | ||
|
|
7deb382f3d | ||
|
|
18fecf8e8c | ||
|
|
3f9b73b927 | ||
|
|
67be35564c | ||
|
|
ea01ad999b | ||
|
|
47268a42af | ||
|
|
b930d60bc3 | ||
|
|
e1902111d8 | ||
|
|
7f9f73d6ba | ||
|
|
9097170b08 | ||
|
|
4567aed5fb | ||
|
|
e1e02f82f1 | ||
|
|
a770845ebf | ||
|
|
45644d3f43 | ||
|
|
348d955a0a | ||
|
|
2d4a537c6a | ||
|
|
370b220834 | ||
|
|
cdc8177fae | ||
|
|
d59e1cf604 | ||
|
|
bc524fa48e | ||
|
|
4d68300bc2 | ||
|
|
22157433ce | ||
|
|
1b4df8acc2 | ||
|
|
9b155c48f7 | ||
|
|
f065f9168a | ||
|
|
e6b6b34970 | ||
|
|
be20ca163d | ||
|
|
cd94a6d7b4 | ||
|
|
ac7595975a | ||
|
|
4bfd0bc5a1 | ||
|
|
01eb237209 | ||
|
|
7ee11fe137 | ||
|
|
f9802ecc9d | ||
|
|
ced7243234 | ||
|
|
34a4ed3ead | ||
|
|
0d18137114 | ||
|
|
d66ed11253 | ||
|
|
33abe3a9f7 | ||
|
|
833fe0da1f | ||
|
|
ac8f6f6ce4 | ||
|
|
2c23b61908 | ||
|
|
7b10212b5a | ||
|
|
686c13c8b7 | ||
|
|
50f8c0d42b | ||
|
|
938762d796 | ||
|
|
d48bcaf043 | ||
|
|
a0ec1fb785 | ||
|
|
1241fbf260 | ||
|
|
62729e3a99 | ||
|
|
ef894c20e5 | ||
|
|
f36bc100d4 | ||
|
|
f92b4d30cb | ||
|
|
450ef1aea0 | ||
|
|
2cae3a22b1 | ||
|
|
3177fbe03f | ||
|
|
139098e174 | ||
|
|
efd081acc0 | ||
|
|
78ffd3b4c3 | ||
|
|
39602e6ce1 | ||
|
|
13691e95e5 | ||
|
|
be1af2d6ac | ||
|
|
531d292930 | ||
|
|
34aa98870d | ||
|
|
72db972de3 | ||
|
|
89439636d8 | ||
|
|
07f6fe0b0b | ||
|
|
736cc89b2b | ||
|
|
d1075c067b | ||
|
|
938097f5b6 | ||
|
|
9f11e26e78 | ||
|
|
f519fd67f6 | ||
|
|
b5aacceb11 | ||
|
|
3f520a3420 | ||
|
|
5e5e131be7 | ||
|
|
d95908f4ba | ||
|
|
c74e1a4d99 | ||
|
|
48696f9bcf | ||
|
|
568dd58264 | ||
|
|
1539500b7f | ||
|
|
a0b96d9920 | ||
|
|
2effccc26c | ||
|
|
71437f23b1 | ||
|
|
e440c8bdc9 | ||
|
|
a091fd9b50 | ||
|
|
2c8ff394cc | ||
|
|
3580a72c0d | ||
|
|
66be0a8646 | ||
|
|
8939e7817f | ||
|
|
01032e9010 | ||
|
|
29c38887de | ||
|
|
08902d925a | ||
|
|
caa9fd0f48 | ||
|
|
18092672ee | ||
|
|
2b53301092 | ||
|
|
694809d6c5 | ||
|
|
5effeea8fc | ||
|
|
209d2447d9 | ||
|
|
00fb1147c3 | ||
|
|
dcef8f12b2 | ||
|
|
d3cc40ca20 | ||
|
|
2d0a7a1e42 | ||
|
|
482c3e9481 | ||
|
|
f8838b15e1 | ||
|
|
c05285e181 | ||
|
|
93338d06ce | ||
|
|
9939819ee3 | ||
|
|
260da4e752 | ||
|
|
785913ed47 | ||
|
|
a98239e1d1 | ||
|
|
4d5360fd90 | ||
|
|
3272097e15 | ||
|
|
4bcc5c87d1 | ||
|
|
8f9338a5ec | ||
|
|
5a7fbfa5b8 | ||
|
|
4ec6a373b0 | ||
|
|
f644004cc6 | ||
|
|
a54cf58f2e | ||
|
|
06c8ff640d | ||
|
|
d0d4d26c74 | ||
|
|
9943e78952 | ||
|
|
d231823e1c | ||
|
|
7b19b74a59 | ||
|
|
2a580f2902 | ||
|
|
1a6979c948 | ||
|
|
180928c001 | ||
|
|
151a279da8 | ||
|
|
9a816f80f7 | ||
|
|
46b2fe2f7a | ||
|
|
f25bded4ef | ||
|
|
cf587ffe3b | ||
|
|
561299bfad | ||
|
|
da1d9d0116 | ||
|
|
194a1204ab | ||
|
|
0019c2c552 | ||
|
|
c45212ac68 | ||
|
|
76c9f4f2af | ||
|
|
7ba3676f5f | ||
|
|
e7d590c8a4 | ||
|
|
43a118c543 |
@@ -0,0 +1,6 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
reviews:
|
||||
auto_review:
|
||||
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
|
||||
ignore_usernames:
|
||||
- "dependabot[bot]"
|
||||
@@ -0,0 +1,30 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain Rust / Cargo dependencies
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
groups:
|
||||
cargo-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# Maintain GitHub Actions workflows
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "ci(deps)"
|
||||
@@ -1,176 +0,0 @@
|
||||
name: Publish Flatpak repository to Pages
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: "Published GitHub release tag to publish (for example, v1.2.3)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: flatpak-pages-${{ github.repository }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Flatpak repository
|
||||
# Pre-releases (rc builds) never reach the stable branch: a published pre-release is skipped
|
||||
# here, and the validation step below refuses a manual tag with a pre-release suffix.
|
||||
if: github.event_name != 'release' || !github.event.release.prerelease
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
# configure-pages only reads the Pages configuration (base_url); deploy needs write.
|
||||
pages: read
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }}
|
||||
APP_ID: ai.storyteller.gridcraft
|
||||
steps:
|
||||
- name: Configure Pages
|
||||
id: pages
|
||||
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
|
||||
|
||||
- name: Install Flatpak repository tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends flatpak ostree
|
||||
|
||||
- name: Download and validate release bundles
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="$RELEASE_TAG"
|
||||
if ! printf '%s' "$tag" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
|
||||
echo "::error::Release tag '$tag' is not strict SemVer prefixed with v"
|
||||
exit 1
|
||||
fi
|
||||
# A pre-release has a "-" before any "+build" metadata (v1.2.3-rc.1).
|
||||
case "${tag%%+*}" in
|
||||
*-*)
|
||||
echo "::error::$tag is a pre-release; only stable releases are published to the Flatpak repository"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
published_tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" --jq \
|
||||
'select(.draft == false and .prerelease == false and .published_at != null) | .tag_name')"
|
||||
if [[ "$published_tag" != "$tag" ]]; then
|
||||
echo "::error::$tag is not a published stable (non-pre-release) release in $GITHUB_REPOSITORY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version="${tag#v}"
|
||||
mkdir -p bundles
|
||||
gh release download "$tag" --repo "$GITHUB_REPOSITORY" \
|
||||
--pattern SHA256SUMS.txt --dir bundles
|
||||
for arch in x86_64 aarch64; do
|
||||
asset="gridcraft-$version-linux-$arch.flatpak"
|
||||
gh release download "$tag" --repo "$GITHUB_REPOSITORY" \
|
||||
--pattern "$asset" --dir bundles
|
||||
test -s "bundles/$asset"
|
||||
(
|
||||
cd bundles
|
||||
awk -v file="$asset" '$2 == file || $2 == "*" file { print; found=1 } END { exit !found }' \
|
||||
SHA256SUMS.txt | sha256sum --check -
|
||||
)
|
||||
done
|
||||
test "$(find bundles -maxdepth 1 -type f -name '*.flatpak' | wc -l)" -eq 2
|
||||
|
||||
for arch in x86_64 aarch64; do
|
||||
asset="bundles/gridcraft-$version-linux-$arch.flatpak"
|
||||
validation_repo="$(mktemp -d)"
|
||||
ostree --repo="$validation_repo" init --mode=archive-z2
|
||||
flatpak build-import-bundle "$validation_repo" "$asset"
|
||||
expected_ref="app/$APP_ID/$arch/stable"
|
||||
ostree --repo="$validation_repo" refs | grep -Fxq "$expected_ref" || {
|
||||
echo "::error::$(basename "$asset") does not contain $expected_ref"
|
||||
exit 1
|
||||
}
|
||||
rm -rf "$validation_repo"
|
||||
done
|
||||
|
||||
- name: Build repository and site
|
||||
shell: bash
|
||||
env:
|
||||
PAGES_BASE_URL: ${{ steps.pages.outputs.base_url }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
site="$RUNNER_TEMP/site"
|
||||
repo_dir="$site/flatpak/repo"
|
||||
mkdir -p "$repo_dir"
|
||||
ostree --repo="$repo_dir" init --mode=archive-z2
|
||||
|
||||
previous_url="${PAGES_BASE_URL%/}/flatpak/repo"
|
||||
if curl --fail --silent --show-error --location \
|
||||
--output /dev/null "$previous_url/summary"; then
|
||||
ostree --repo="$repo_dir" remote add --no-gpg-verify previous "$previous_url"
|
||||
ostree --repo="$repo_dir" pull --mirror previous
|
||||
ostree --repo="$repo_dir" remote delete previous
|
||||
fi
|
||||
|
||||
version="${RELEASE_TAG#v}"
|
||||
for arch in x86_64 aarch64; do
|
||||
flatpak build-import-bundle "$repo_dir" \
|
||||
"bundles/gridcraft-$version-linux-$arch.flatpak"
|
||||
done
|
||||
# Prune unreachable objects and keep at most 3 commits per ref, so the Pages repository
|
||||
# (re-mirrored and re-deployed on every release) does not grow without bound.
|
||||
flatpak build-update-repo \
|
||||
--prune --prune-depth=3 \
|
||||
--title="GridCraft" \
|
||||
--comment='Official GridCraft releases' \
|
||||
--description='Official Flatpak repository for GridCraft' \
|
||||
--homepage="https://github.com/$GITHUB_REPOSITORY" \
|
||||
--default-branch=stable \
|
||||
"$repo_dir"
|
||||
|
||||
for arch in x86_64 aarch64; do
|
||||
ostree --repo="$repo_dir" rev-parse "app/$APP_ID/$arch/stable" >/dev/null
|
||||
done
|
||||
test -s "$repo_dir/summary"
|
||||
|
||||
base_url="${PAGES_BASE_URL%/}"
|
||||
repo_url="$base_url/flatpak/repo/"
|
||||
cat >"$site/flatpak/gridcraft.flatpakrepo" <<META
|
||||
[Flatpak Repo]
|
||||
Title=GridCraft
|
||||
Comment=Official GridCraft releases
|
||||
Description=Official Flatpak repository for GridCraft
|
||||
Url=$repo_url
|
||||
Homepage=https://github.com/$GITHUB_REPOSITORY
|
||||
META
|
||||
cat >"$site/flatpak/index.html" <<HTML
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>GridCraft Flatpak repository</title></head><body><main><h1>GridCraft Flatpak repository</h1><p>Latest published release: <strong>$RELEASE_TAG</strong></p><p><a href="gridcraft.flatpakrepo">Add the GridCraft repository</a></p><pre>flatpak remote-add --if-not-exists gridcraft $base_url/flatpak/gridcraft.flatpakrepo
|
||||
flatpak install gridcraft $APP_ID</pre></main></body></html>
|
||||
HTML
|
||||
cat >"$site/index.html" <<HTML
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>GridCraft downloads</title></head><body><main><h1>GridCraft downloads</h1><p><a href="flatpak/">Flatpak repository</a></p><p><a href="https://github.com/$GITHUB_REPOSITORY/releases/tag/$RELEASE_TAG">Release assets</a></p></main></body></html>
|
||||
HTML
|
||||
touch "$site/.nojekyll"
|
||||
|
||||
- name: Upload Pages artifact
|
||||
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
|
||||
with:
|
||||
path: ${{ runner.temp }}/site
|
||||
include-hidden-files: true
|
||||
|
||||
deploy:
|
||||
name: Deploy Pages
|
||||
needs: build
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
|
||||
@@ -1,95 +1,27 @@
|
||||
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
|
||||
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
|
||||
# Test builds run only from the `release` branch (a manual run from any other branch is skipped).
|
||||
# Packaging runs (release.yml calls it that way) run from any branch, so a release.yml dry run
|
||||
# from a branch builds the tarball too; they need no secrets.
|
||||
#
|
||||
# With `package: true` (release.yml calls it that way, or a manual run) it instead builds the
|
||||
# release binaries and uploads `gridcraft-<version>-freebsd-x86_64.tar.gz` as the
|
||||
# `release-freebsd` artifact (packaging/freebsd/package.sh), which release.yml attaches to the
|
||||
# draft GitHub Release.
|
||||
name: FreeBSD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [release]
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/freebsd.yml
|
||||
- packaging/freebsd/**
|
||||
- packaging/env.sh
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
package:
|
||||
description: "Build the release tarball instead of running the tests"
|
||||
type: boolean
|
||||
default: false
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version for the artifact name (empty: Cargo.toml)"
|
||||
type: string
|
||||
default: ""
|
||||
version_override:
|
||||
description: "Also write the version into Cargo.toml before building (test runs)"
|
||||
type: boolean
|
||||
default: false
|
||||
package:
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
# Called from release.yml, github.workflow is the caller's name; keep the groups apart.
|
||||
group: freebsd-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.workflow == 'FreeBSD' }}
|
||||
group: freebsd-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
freebsd:
|
||||
name: FreeBSD 14 (${{ inputs.package && 'package' || 'build + test' }})
|
||||
if: github.ref == 'refs/heads/release' || inputs.package
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 300
|
||||
env:
|
||||
GRIDCRAFT_VERSION: ${{ inputs.version }}
|
||||
GRIDCRAFT_VERSION_OVERRIDE: ${{ inputs.version_override && '1' || '' }}
|
||||
GRIDCRAFT_PACKAGE: ${{ inputs.package && '1' || '' }}
|
||||
GRIDCRAFT_BUILD_SHA: ${{ github.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
path: craft-fonts
|
||||
- name: Build in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
envs: "GRIDCRAFT_VERSION GRIDCRAFT_VERSION_OVERRIDE GRIDCRAFT_PACKAGE GRIDCRAFT_BUILD_SHA"
|
||||
# Only the packaging run brings files back (dist/release). The target dir lives outside
|
||||
# the synced checkout, because copying it out of the VM fails on large builds.
|
||||
copyback: ${{ inputs.package }}
|
||||
prepare: |
|
||||
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
# The checkout is synced in under another owner; let tests that call git read it.
|
||||
git config --global --add safe.directory "*"
|
||||
export CARGO_TARGET_DIR=/var/tmp/gridcraft-target CARGO_PROFILE_DEV_DEBUG=0
|
||||
rustc --version
|
||||
if [ -n "$GRIDCRAFT_PACKAGE" ]; then
|
||||
if [ -n "$GRIDCRAFT_VERSION_OVERRIDE" ]; then
|
||||
cargo run -q -p xtask --no-default-features -- version set "$GRIDCRAFT_VERSION"
|
||||
fi
|
||||
CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1 bash packaging/freebsd/package.sh
|
||||
else
|
||||
cargo build --workspace --all-targets --locked
|
||||
CRAFT_FONTS_DIR="$PWD/craft-fonts" cargo test --workspace --locked
|
||||
fi
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
if: inputs.package
|
||||
with:
|
||||
name: release-freebsd
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
# 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/freebsd.yml@main
|
||||
with:
|
||||
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
|
||||
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
pull_request:
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nix-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
nix:
|
||||
# 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/nix.yml@main
|
||||
@@ -0,0 +1,20 @@
|
||||
name: SonarCloud
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
# Tracked at @main so org-wide CI improvements propagate without PR churn.
|
||||
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
|
||||
secrets: inherit
|
||||
@@ -26,7 +26,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
||||
- **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).
|
||||
- **Bound recursion** (formula nesting, dependency chains, names referring to names).
|
||||
- **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence.
|
||||
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
|
||||
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/it/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
|
||||
- Crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
|
||||
## Non-negotiables
|
||||
@@ -37,6 +37,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
||||
- **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.**
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math). A crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). One task id per commit (`M2.1: borders gallery`).
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ People trust GridCraft with their numbers; a crash loses their work. **This outr
|
||||
- **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).
|
||||
- **Bound recursion** (formula nesting, dependency chains, names referring to names).
|
||||
- **Last-resort guard:** `Session::execute` catches escaped panics and restores the workbook. It's a safety net, not a licence.
|
||||
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
|
||||
- **Prove it:** every crash fix lands with a regression test (`tests::every_command_survives_empty_params` fuzzes every command; `crates/engine/tests/it/command_sequence_fuzz.rs` fuzzes random command sequences against undo/redo, XLSX round-trip and full-recalc invariants; soak it with `GRIDCRAFT_FUZZ_SEEDS`/`GRIDCRAFT_FUZZ_STEPS`, replay with `GRIDCRAFT_FUZZ_SEED`).
|
||||
- Crates carry `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
|
||||
## Non-negotiables
|
||||
|
||||
Generated
+27
-16
@@ -619,6 +619,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.5.2"
|
||||
@@ -1496,9 +1505,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft"
|
||||
version = "0.7.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
"craft-print",
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -1518,7 +1528,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-calc"
|
||||
version = "0.7.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
@@ -1530,7 +1540,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-chart"
|
||||
version = "0.7.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"gridcraft-calc",
|
||||
"gridcraft-core",
|
||||
@@ -1541,7 +1551,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-cli"
|
||||
version = "0.7.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"gridcraft-mcp",
|
||||
@@ -1553,8 +1563,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-core"
|
||||
version = "0.7.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"js-sys",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1563,7 +1574,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-engine"
|
||||
version = "0.7.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"gridcraft-calc",
|
||||
"gridcraft-chart",
|
||||
@@ -1584,7 +1595,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-formula"
|
||||
version = "0.7.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"serde",
|
||||
@@ -1593,7 +1604,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-functions"
|
||||
version = "0.7.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"regex",
|
||||
@@ -1601,7 +1612,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-mcp"
|
||||
version = "0.7.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"log",
|
||||
@@ -1610,7 +1621,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-model"
|
||||
version = "0.7.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
@@ -1622,18 +1633,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-numfmt"
|
||||
version = "0.7.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-pdf"
|
||||
version = "0.7.0"
|
||||
version = "0.11.1"
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-ui-egui"
|
||||
version = "0.7.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
@@ -1655,7 +1666,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-web"
|
||||
version = "0.7.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -1672,7 +1683,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-xlsx"
|
||||
version = "0.7.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"gridcraft-core",
|
||||
@@ -4526,7 +4537,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.7.0"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"serde_json",
|
||||
|
||||
+5
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.7.0"
|
||||
version = "0.11.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -30,6 +30,8 @@ thiserror = "2"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
log = "0.4"
|
||||
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
rayon = "1"
|
||||
regex = { version = "1", default-features = false, features = ["std", "unicode", "perf"] }
|
||||
quick-xml = "0.41"
|
||||
@@ -66,9 +68,11 @@ type_complexity = "allow"
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
debug = false
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
|
||||
@@ -7,14 +7,18 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.gridcraft.png" width="128" height="128" alt="GridCraft icon: a spreadsheet page on a green tile">
|
||||
</p>
|
||||
|
||||
<h1 align="center">GridCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>Spreadsheets and data; an open-source, clean-room reimplementation of Microsoft Excel, rebuilt in pure Rust.</b>
|
||||
<b>Spreadsheets and data; an open-source, high-performance modern spreadsheet application, built in pure Rust.</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
A fast, open-source, clean-room take on the Excel workflow. It runs natively on macOS,
|
||||
A fast, open-source, clean-room take on modern spreadsheet workflows. It runs natively on macOS,
|
||||
Windows, Linux and BSD, and in the browser via WebAssembly.<br>
|
||||
<i>By the ArtCraft team.</i>
|
||||
</p>
|
||||
@@ -24,6 +28,9 @@
|
||||
<img alt="Runs on macOS, Windows, Linux, BSD and the web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20BSD%20%C2%B7%20Web-1f9d55?style=flat-square">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-147a40?style=flat-square">
|
||||
<img alt="Agent-drivable over MCP" src="https://img.shields.io/badge/agents-MCP-1f9d55?style=flat-square">
|
||||
<br>
|
||||
<a href="https://sonarcloud.io/project/overview?id=storytold_gridcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
|
||||
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/gridcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -67,8 +74,8 @@
|
||||
<td width="50%" valign="top"><img src="docs/images/grades-formulas.png" alt="A gradebook table with averages and letter grades computed with IFS, green highlights on A grades and data bars, with the Formulas ribbon tab open showing the function library" width="100%"><p align="center"><sub><b>Formulas.</b> 500+ functions, dynamic arrays, LET and LAMBDA, structured table references.</sub></p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top"><img src="docs/images/dark-insert.png" alt="The sales dashboard in dark mode with the Insert ribbon tab open, showing PivotTable, Table, Pictures, Shapes, chart, sparkline, link, comment, text box and symbol buttons" width="100%"><p align="center"><sub><b>Dark mode</b> and the Insert tab: tables, charts, sparklines, pictures, shapes, links and comments.</sub></p></td>
|
||||
<td width="50%" valign="top"><img src="docs/images/pivot.png" alt="A PivotTable summarising revenue by region and product with a Channel report filter, grand totals and currency formatting, with the PivotTable Fields pane open on the right showing ticked fields and the Filters, Columns, Rows and Values areas" width="100%"><p align="center"><sub><b>PivotTables.</b> Field list, filters, layouts, date grouping and show-values-as, saved to and read from XLSX.</sub></p></td>
|
||||
<td width="50%" valign="top"><img src="docs/images/dark-insert.png" alt="The sales dashboard in dark mode with the Insert ribbon tab open, showing Pivot Table, Table, Pictures, Shapes, chart, sparkline, link, comment, text box and symbol buttons" width="100%"><p align="center"><sub><b>Dark mode</b> and the Insert tab: tables, charts, sparklines, pictures, shapes, links and comments.</sub></p></td>
|
||||
<td width="50%" valign="top"><img src="docs/images/pivot.png" alt="A pivot table summarising revenue by region and product with a Channel report filter, grand totals and currency formatting, with the Pivot Table Fields pane open on the right showing ticked fields and the Filters, Columns, Rows and Values areas" width="100%"><p align="center"><sub><b>Pivot tables.</b> Field list, filters, layouts, date grouping and show-values-as, saved to and read from XLSX.</sub></p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top"><img src="docs/images/format-chart.png" alt="The sales dashboard with the column chart selected: resize handles on the chart, its source data outlined in the table, the Chart Design tab open and the Format Chart Area pane on the right" width="100%"><p align="center"><sub><b>Charts.</b> Seventeen chart types, Chart Design tab and a Format pane; selecting a chart outlines its source data.</sub></p></td>
|
||||
@@ -78,19 +85,19 @@
|
||||
|
||||
## Why GridCraft
|
||||
|
||||
- **Familiar.** Excel's ribbon, formula bar, Name Box, sheet tabs, keyboard shortcuts, fill
|
||||
handle, point-mode formula entry with coloured references, Format Cells, Paste Special,
|
||||
conditional formatting, tables, charts and more. If you know Excel, you already know GridCraft.
|
||||
- **Familiar.** Ribbon, formula bar, Name Box, sheet tabs, keyboard shortcuts, fill handle,
|
||||
point-mode formula entry with coloured references, Format Cells, Paste Special,
|
||||
conditional formatting, tables, charts and more. Everything is right where you expect it.
|
||||
- **Compatible.** XLSX is the native format: styles, themes, formulas (including dynamic arrays),
|
||||
tables, conditional formats, validation, comments, hyperlinks, charts, pictures, sparklines,
|
||||
PivotTables and print settings round-trip. CSV and TSV too.
|
||||
Excel 97-2003 `.xls` workbooks open with their values, formulas and number formats (formatting and objects not yet).
|
||||
pivot tables and print settings round-trip. CSV and TSV too.
|
||||
Legacy 97-2003 `.xls` workbooks open with their values, formulas and number formats (formatting and objects not yet).
|
||||
[XLSB worksheet data can be imported](docs/xlsb-import.md), using saved values instead of formulas.
|
||||
[ODS worksheet data can be imported](docs/ods-import.md), using saved values instead of formulas.
|
||||
[Static pictures can live inside cells](docs/cell-pictures.md), fitting their size and moving with the data.
|
||||
- **Capable.** A dependency-graph calculation engine with dynamic arrays and spilling,
|
||||
500+ worksheet functions plus LET, LAMBDA, MAP, REDUCE, SCAN, BYROW, BYCOL and MAKEARRAY,
|
||||
structured table references, Excel's full number-format language, sort and AutoFilter,
|
||||
structured table references, a comprehensive number-format language, sort and AutoFilter,
|
||||
Flash Fill, data validation, outlining and subtotals.
|
||||
- **Fast.** Copy-on-write workbooks (undo snapshots are nearly free), incremental recalculation,
|
||||
and a virtualised grid that only lays out what's on screen.
|
||||
@@ -119,7 +126,7 @@ cd apps/gridcraft-web && trunk build --release # → dist/web (serve it with
|
||||
cd apps/gridcraft-web && trunk serve --release # http://127.0.0.1:8771 (?sample=sales opens a sample)
|
||||
```
|
||||
|
||||
You need [trunk](https://trunkrs.dev) and the `wasm32-unknown-unknown` target. Open uses the
|
||||
You need [trunk](https://trunk-rs.github.io/trunk/) and the `wasm32-unknown-unknown` target. Open uses the
|
||||
browser's file picker (dropping files works too) and Save downloads the workbook.
|
||||
|
||||
### Ribbon access keys
|
||||
@@ -187,7 +194,7 @@ egui frontend is a separate crate, so the UI can be swapped without touching the
|
||||
| L2 | `model` (workbook, sheets, copy-on-write cells, styles, tables, charts…) · `calc` (dependency graph, evaluator, dynamic arrays) · `xlsx` (XLSX, CSV, plus XLSB and ODS data import) |
|
||||
| L3 | `chart` (toolkit-free chart layout and rendering) |
|
||||
| L5 | `engine` (session, commands, history, clipboard, fill, sort, filter, file I/O) |
|
||||
| L6 | `ui-egui` (Excel-style UI, control channel) · `mcp` (MCP server) |
|
||||
| L6 | `ui-egui` (spreadsheet UI, control channel) · `mcp` (MCP server) |
|
||||
| L7 | `apps/gridcraft`, `apps/gridcraft-cli`, `apps/gridcraft-web` |
|
||||
|
||||
- **Contributor and agent rules** (clean-room, asset policy, never-crash, quality gates): [`AGENTS.md`](AGENTS.md)
|
||||
@@ -197,9 +204,9 @@ egui frontend is a separate crate, so the UI can be swapped without touching the
|
||||
## Roadmap
|
||||
|
||||
GridCraft is in **alpha**: the core workflow works end to end, but depth, fidelity and file
|
||||
compatibility are still rough. It covers 89% of the Excel ribbon commands in our catalog
|
||||
([`docs/parity-checklist.md`](docs/parity-checklist.md)) and 513 of Excel's 523 worksheet functions
|
||||
([`docs/function-parity.md`](docs/function-parity.md)); weighted by what Excel users rely on, we
|
||||
compatibility are still rough. It covers 89% of standard ribbon commands in our catalog
|
||||
([`docs/parity-checklist.md`](docs/parity-checklist.md)) and 513 standard worksheet functions
|
||||
([`docs/function-parity.md`](docs/function-parity.md)); this is a parity inventory, not a count of working implementations. Weighted by what spreadsheet users rely on, we
|
||||
estimate it is about 50% ready for real work. Next up: XLSX fidelity on real-world files, very large
|
||||
workbooks and multi-threaded recalc, chart depth, text in every script, and `.xls` import. Stage,
|
||||
numbers and estimates are in [ROADMAP.md](ROADMAP.md); the ranked work list is [`docs/gaps.md`](docs/gaps.md).
|
||||
@@ -236,7 +243,7 @@ bring back a crash in some Intel UHD Graphics drivers (`igvk64.dll`, #120). The
|
||||
web backend defaults are unchanged.
|
||||
|
||||
**If the app is slow on an older Windows PC, or DirectX 12 can't start:** when DirectX 12 finds no
|
||||
graphics card with a driver for it, it only offers Microsoft's software renderer, which draws every
|
||||
graphics card with a driver for it, it only offers the Windows-provided software renderer, which draws every
|
||||
frame on the processor. In that case GridCraft uses OpenGL instead, without setting anything; if
|
||||
the first renderer fails to start, it tries the other one once. The log file records the choice
|
||||
(lines starting with `graphics:`). `WGPU_BACKEND` still overrides it (and then nothing else is
|
||||
@@ -334,8 +341,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
|
||||
part of this repository and GridCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Microsoft, Excel and Microsoft 365 are trademarks or registered trademarks of Microsoft Corporation in the United States and/or other countries. GridCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Microsoft Corporation; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
|
||||
+50
-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-12 · **Change:** minor (rows auto-fit oversized fonts on open and whole-row/column formatting) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-22 · **Change:** trivial (progress log: formula hardening, #2163/#2162; previously minor: Page Setup paper sizes: JIS B4/B5/B6, the Japanese postcards and envelopes, and A6; long flat operator chains up to Excel's formula limit) · **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.
|
||||
@@ -21,6 +21,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%) |
|
||||
| **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) |
|
||||
@@ -93,7 +94,9 @@ The ribbon is translated into Japanese, Simplified Chinese, Korean, Brazilian Po
|
||||
and German (266–268 labels each, ~12% of ~2,200 strings; merged 10-10 in #31, #104, #82, German
|
||||
added 10-11), the
|
||||
interface follows the system language, and CJK, Thai and Hebrew text falls back to system fonts
|
||||
(#38). Dialogs, menus and messages are English; formulas and number formats are en-US only.
|
||||
(#38). Japanese (10-17) and Brazilian Portuguese also translate the dialog titles and labels,
|
||||
message boxes, Backstage/File-view items, template names and native menus: 943 strings each, ~43%.
|
||||
The remaining dialogs, menus and messages are English; formulas and number formats are en-US only.
|
||||
Localized German formulas and numbers are in PR #163. Detail: [localization-parity.md](docs/localization-parity.md).
|
||||
|
||||
| Language | Code | Status | UI strings |
|
||||
@@ -104,14 +107,14 @@ Localized German formulas and numbers are in PR #163. Detail: [localization-pari
|
||||
| Hindi | hi | none | 0% |
|
||||
| Arabic | ar | none (no RTL layout, no shaping) | 0% |
|
||||
| French | fr | none | 0% |
|
||||
| Portuguese | pt-BR | menus only | ~12% |
|
||||
| Portuguese | pt-BR | menus, File view, dialogs | ~43% |
|
||||
| Indonesian | id | none | 0% |
|
||||
| Japanese | ja | menus only | ~12% |
|
||||
| Japanese | ja | menus, File view, dialogs, messages | ~43% |
|
||||
| German | de | menus only | ~12% |
|
||||
| Korean | ko | menus only | ~12% |
|
||||
| Vietnamese | vi | none | 0% |
|
||||
|
||||
Other shipped languages: 2 (Russian and German, menus only, ~12%).
|
||||
Other shipped languages: 3 (Russian and German, menus only, ~12%; Dutch, menus, File view and dialog titles, ~24%).
|
||||
|
||||
## Upcoming
|
||||
|
||||
@@ -132,6 +135,31 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-22 | Formula hardening: digits too many for a finite number are text, so `"1 <400 digits>/<400 digits>"` no longer coerces to NaN; a NaN column index in VLOOKUP, HLOOKUP and the D-functions is `#VALUE!` instead of an index underflow; REGEXREPLACE stops building a result as soon as it can only be too long (a worst case went from 1 GB to 7 MB) (#2163, #2162) |
|
||||
| 2026-10-21 | Page Setup paper sizes match Japanese Excel: `B4`/`B5`/`B6` are the JIS sizes (ISO B4/B5/B6 stay reachable by name), so a Japanese B5 worksheet prints at 182×257 mm; Japanese Postcard (100×148 mm), Double Postcard (148×200 mm), Envelope #3/#4 (120×235/90×205 mm), Kaku #2/#3 (240×332/216×277 mm) and A6 are added, both sizes reach the PDF page (`file.exportPdf`/`file.print` MediaBox) and the size survives an XLSX round-trip; the Page Layout Size menu lists them. `B4`/`B5` used to be the ISO sizes, which is changed here |
|
||||
| 2026-10-20 | Japanese interface catalog covers every key the Brazilian Portuguese catalog translates: the 398-label shared core plus 545 dialog, message, Backstage and menu strings (943 total, ~43% of ~2,200). A test parses `pt.rs`'s source, so the two catalogs stay in step |
|
||||
| 2026-10-20 | Interface translated into Dutch (518 labels: ribbon, context menus, File view, dialog titles, Options); `nl`, `nl-NL`, `nl-BE` locale tags resolve to Dutch |
|
||||
| 2026-10-19 | Long flat operator chains (`=A1+A2+…`) up to Excel's 8,192-character limit (4,096 operands): entering, filling, inserting rows, undo, Evaluate Formula and XLSX save no longer overflow the stack; chains past 64 operands are saved as plain formulas, not dynamic arrays; Spanish formula entry accepts chains past 256 operands (#916) |
|
||||
| 2026-10-19 | Linux: "Use system setting" for the theme keeps following the desktop when the XDG Desktop Portal stops answering: each read gives up after 2 s and the next poll reconnects, where one unanswered call stalled the watcher for the rest of the session |
|
||||
| 2026-10-16 | Memory: undo steps share a sheet's picture bytes and the workbook's VBA project instead of copying them: 50 edits on a sheet with a 20 MB picture keep 22 MB instead of 992 MB |
|
||||
| 2026-10-16 | Memory: floating pictures are decoded at about the size they are drawn, with limits, into a cache bounded by bytes and dropped with their workbook, instead of at full size into one that never let go; two open workbooks no longer show each other's picture. 12 large pictures scrolled through keep 40 MB of textures instead of 258 MB, and none once the workbook closes |
|
||||
| 2026-10-16 | The web app keeps its preferences between visits (#1562): theme (dark mode), interface size, language, editing options and the rest of what the desktop app saves in `ui.json` and `prefs.json` are kept in the browser's `localStorage`, written as soon as they change |
|
||||
| 2026-10-16 | Memory: the interface and script fallback fonts are read once per run and shared with egui instead of copied into it: an empty workbook settles at 158 MB instead of 258 MB on macOS |
|
||||
| 2026-10-15 | Function help follows the interface language: argument hints, autocomplete, Insert Function (search and readable categories) and the Formulas-tab menus read per-language tables of descriptions and argument names; Spanish keeps its 10 functions in the new format |
|
||||
| 2026-10-15 | Row auto-fit counts a cell's line breaks only when the cell wraps, as Excel does: a file whose unwrapped cells hold multi-line text no longer opens with tall rows (typing a break still turns wrap on and grows the row) |
|
||||
| 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 | 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 |
|
||||
| 2026-10-13 | Formula-entry Command/Ctrl-click inserts another reference with the locale's argument separator; Shift-click extends from a stable, sheet-aware anchor. Pointer tests cover repeated clicks, reversed ranges, ordinary replacement, typed separators, Spanish syntax, sheet switches and keyboard continuation. Readiness estimates unchanged. |
|
||||
| 2026-10-13 | `formulas.evaluate` (`gridcraft-cli eval`, MCP `evaluate_formula`) refuses what entering the formula in a cell refuses, with the reason (`=SUM(1;2)`: "expected `,` or `)`"), instead of `#NAME?` for any formula that doesn't parse |
|
||||
| 2026-10-13 | Colour menus: Fill Color, Font Color and the Format Cells pickers end with Recent Colors (the last ten custom colours) and More Colors… (hue/saturation area, RGB, hex box, preview, OK/Cancel) (#928) |
|
||||
| 2026-10-13 | Multi-level category axes: a category range two or more columns wide beside a column of values (year beside month, Excel's `multiLvlStrRef`) draws its innermost labels across the plot and the outer levels in rows under them with group separators (columns beside the labels in bar charts), instead of flattening the two columns into twice as many categories; saving writes the reference back as `multiLvlStrRef` with Excel's level caches, so the axis survives a round trip |
|
||||
| 2026-10-13 | Charts: a huge trendline forecast no longer collapses the chart: on a category axis it adds at most four times the data in empty slots (at least 32), the line is sampled over the plot only, and forecasts read from XLSX or set by command are clamped, NaN and infinity becoming 0 (#1148)
|
||||
| 2026-10-13 | XLSX export writes a picture used on several sheets once, as one `xl/media` part every drawing points to (it wrote a copy per drawing: a 16-sheet workbook with one 112 KB logo grew from 0.4 to 1.9 MB) (#985) |
|
||||
| 2026-10-13 | XLSX: `[Content_Types].xml`, `_rels/.rels`, the workbook rels and `xl/workbook.xml` are the first zip entries (document properties last), so libmagic and PHP `finfo` detect the file type even with large properties (#1090) |
|
||||
| 2026-10-13 | Conditional formatting: a rule gathers range statistics only when it reads them (top/bottom, above average, duplicates, scales, bars, icons) and borrows them instead of copying them per cell, so the per-frame lookup of an expression or cell-value rule no longer visits its whole range (#1098, part 1) |
|
||||
| 2026-10-12 | Next/previous sheet navigation stops at the first/last visible worksheet instead of activating a hidden boundary sheet; the current selection is retained |
|
||||
| 2026-10-12 | 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) |
|
||||
| 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) |
|
||||
@@ -193,6 +221,23 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-22 | trivial | Progress log: formula hardening (#2163, #2162) |
|
||||
| 2026-10-21 | minor | Page Setup paper sizes: JIS B4/B5/B6 (ISO by name), Japanese postcards and envelopes, A6; PDF page size follows, XLSX round-trips |
|
||||
| 2026-10-20 | minor | Dutch (nl) listed under other shipped languages; progress log row |
|
||||
| 2026-10-19 | minor | Progress log: long flat operator chains (#916) |
|
||||
| 2026-10-19 | trivial | Progress log: desktop theme portal read timeout |
|
||||
| 2026-10-16 | minor | Progress log: undo steps share picture and macro bytes |
|
||||
| 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 | XLSX dynamic-array `cm` metadata kept on save; progress log row |
|
||||
| 2026-10-14 | minor | Progress log: spill anchor reading its old spill area |
|
||||
| 2026-10-14 | minor | Progress log: formulas reading an array that loses a spill conflict |
|
||||
| 2026-10-13 | trivial | Progress log: More Colors… and Recent Colors in the colour menus (#928) |
|
||||
| 2026-10-13 | minor | Multi-level category axes (read, drawn, written); progress log row |
|
||||
| 2026-10-13 | trivial | Progress log: shared pictures written once on XLSX export (#985) |
|
||||
| 2026-10-13 | minor | Progress log: conditional-format lookups no longer scan the range for rules without statistics (#1098) |
|
||||
| 2026-10-12 | minor | Progress log: hidden-sheet navigation at workbook boundaries |
|
||||
| 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row |
|
||||
| 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) |
|
||||
| 2026-10-12 | trivial | Progress log: XLSX chart sheets kept on save (#727) |
|
||||
|
||||
+115
-36
@@ -139,9 +139,10 @@ impl RoundTrip {
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct FileReport {
|
||||
pub path: String,
|
||||
/// GridCraft couldn't open the file: its message.
|
||||
/// Opening the file or discovering the requested inputs failed: its message.
|
||||
pub error: Option<String>,
|
||||
pub warnings: usize,
|
||||
/// Import warnings, in the order the importer produced them.
|
||||
pub warnings: Vec<String>,
|
||||
pub calc: CalcReport,
|
||||
pub round_trip: RoundTrip,
|
||||
}
|
||||
@@ -204,67 +205,112 @@ pub fn main(args: &[String]) -> ExitCode {
|
||||
Err(_) => return usage(format!("--max-report needs a whole number, not `{n}`")),
|
||||
}
|
||||
}
|
||||
let files = collect_inputs(&a.positional);
|
||||
let reports: Vec<FileReport> = files
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let path = p.to_string_lossy().into_owned();
|
||||
match gridcraft_engine::io::read_file(&path) {
|
||||
Ok(bytes) => check_bytes(&path, &bytes, &opts),
|
||||
Err(e) => FileReport { path, error: Some(e.to_string()), ..Default::default() },
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let Discovery { files, failures: mut reports } = collect_inputs(&a.positional);
|
||||
reports.extend(files.iter().map(|p| {
|
||||
let path = p.to_string_lossy().into_owned();
|
||||
match gridcraft_engine::io::read_file(&path) {
|
||||
Ok(bytes) => check_bytes(&path, &bytes, &opts),
|
||||
Err(e) => FileReport { path, error: Some(e.to_string()), ..Default::default() },
|
||||
}
|
||||
}));
|
||||
if a.has("json") {
|
||||
outln!("{}", pretty(&report_json(&reports, &opts)));
|
||||
} else {
|
||||
print_human(&reports, &opts);
|
||||
}
|
||||
if reports.iter().all(FileReport::is_clean) { ExitCode::SUCCESS } else { ExitCode::FAILURE }
|
||||
if !files.is_empty() && reports.iter().all(FileReport::is_clean) { ExitCode::SUCCESS } else { ExitCode::FAILURE }
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Discovery {
|
||||
files: Vec<PathBuf>,
|
||||
failures: Vec<FileReport>,
|
||||
}
|
||||
|
||||
impl Discovery {
|
||||
fn fail(&mut self, path: &Path, error: String) {
|
||||
self.failures.push(FileReport { path: path.to_string_lossy().into_owned(), error: Some(error), ..Default::default() });
|
||||
}
|
||||
|
||||
/// False means the file budget is exhausted: the remaining scan is incomplete.
|
||||
fn add_file(&mut self, path: PathBuf) -> bool {
|
||||
if self.files.len() >= MAX_FILES {
|
||||
self.fail(&path, format!("file limit of {MAX_FILES} reached; this and remaining inputs were not checked"));
|
||||
return false;
|
||||
}
|
||||
self.files.push(path);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Files named on the command line are checked whatever their extension; directories contribute
|
||||
/// their `.xlsx`/`.xlsm` files, recursively, in name order. Hidden files and Excel lock files
|
||||
/// (`~$…`) are skipped.
|
||||
fn collect_inputs(inputs: &[String]) -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
fn collect_inputs(inputs: &[String]) -> Discovery {
|
||||
let mut out = Discovery::default();
|
||||
for i in inputs {
|
||||
let p = PathBuf::from(i);
|
||||
if p.is_dir() {
|
||||
walk(&p, 0, &mut out);
|
||||
} else {
|
||||
// A missing path is reported as a file that failed to open.
|
||||
out.push(p);
|
||||
}
|
||||
if out.len() >= MAX_FILES {
|
||||
out.truncate(MAX_FILES);
|
||||
if !walk(&p, 0, &mut out) {
|
||||
break;
|
||||
}
|
||||
} else if !out.add_file(p) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if out.files.is_empty()
|
||||
&& out.failures.is_empty()
|
||||
&& let Some(input) = inputs.first()
|
||||
{
|
||||
out.fail(Path::new(input), "no workbook files were found in the requested inputs".into());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn walk(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
|
||||
if depth > MAX_DEPTH || out.len() >= MAX_FILES {
|
||||
return;
|
||||
/// Returns false only when the global file budget stops traversal.
|
||||
fn walk(dir: &Path, depth: usize, out: &mut Discovery) -> bool {
|
||||
if depth > MAX_DEPTH {
|
||||
out.fail(dir, format!("directory depth limit of {MAX_DEPTH} exceeded; this subtree was not checked"));
|
||||
return true;
|
||||
}
|
||||
let rd = match std::fs::read_dir(dir) {
|
||||
Ok(rd) => rd,
|
||||
Err(e) => {
|
||||
out.fail(dir, format!("cannot read directory: {e}"));
|
||||
return true;
|
||||
}
|
||||
};
|
||||
let mut entries = Vec::new();
|
||||
for entry in rd {
|
||||
match entry {
|
||||
Ok(entry) => entries.push(entry.path()),
|
||||
Err(e) => out.fail(dir, format!("cannot read directory entry: {e}")),
|
||||
}
|
||||
}
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return };
|
||||
let mut entries: Vec<PathBuf> = rd.flatten().map(|e| e.path()).collect();
|
||||
entries.sort();
|
||||
for p in entries {
|
||||
let name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
if name.starts_with('.') || name.starts_with("~$") {
|
||||
continue;
|
||||
}
|
||||
if p.is_dir() {
|
||||
walk(&p, depth + 1, out);
|
||||
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("xlsx") || e.eq_ignore_ascii_case("xlsm")) {
|
||||
out.push(p);
|
||||
}
|
||||
if out.len() >= MAX_FILES {
|
||||
return;
|
||||
let metadata = match p.metadata() {
|
||||
Ok(metadata) => metadata,
|
||||
Err(e) => {
|
||||
out.fail(&p, format!("cannot inspect input: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if metadata.is_dir() {
|
||||
if !walk(&p, depth + 1, out) {
|
||||
return false;
|
||||
}
|
||||
} else if p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("xlsx") || e.eq_ignore_ascii_case("xlsm"))
|
||||
&& !out.add_file(p)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Checks one file's bytes. Never fails: problems are recorded in the report. A panic escaping
|
||||
@@ -287,7 +333,7 @@ fn check_inner(path: &str, bytes: &[u8], opts: &Options) -> FileReport {
|
||||
return report;
|
||||
}
|
||||
};
|
||||
report.warnings = warnings.len();
|
||||
report.warnings = warnings;
|
||||
let mut fresh = cached.clone();
|
||||
let mut calc = Calc::new();
|
||||
calc.recalc_all(&mut fresh);
|
||||
@@ -1270,6 +1316,30 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn discovery_reports_a_directory_read_error() {
|
||||
let path = std::env::temp_dir().join(format!("gridcraft-check-not-a-directory-{}", std::process::id()));
|
||||
std::fs::write(&path, "not a directory").unwrap();
|
||||
let mut discovery = Discovery::default();
|
||||
assert!(walk(&path, 0, &mut discovery));
|
||||
assert!(discovery.files.is_empty());
|
||||
assert_eq!(discovery.failures.len(), 1);
|
||||
assert_eq!(discovery.failures[0].path, path.to_string_lossy());
|
||||
assert!(discovery.failures[0].error.as_ref().unwrap().contains("cannot read directory"));
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_reports_omissions_only_after_the_file_limit() {
|
||||
let mut discovery = Discovery { files: vec![PathBuf::from("book.xlsx"); MAX_FILES - 1], ..Default::default() };
|
||||
assert!(discovery.add_file(PathBuf::from("last.xlsx")));
|
||||
assert!(discovery.failures.is_empty());
|
||||
assert!(!discovery.add_file(PathBuf::from("omitted.xlsx")));
|
||||
assert_eq!(discovery.files.len(), MAX_FILES);
|
||||
assert_eq!(discovery.failures[0].path, "omitted.xlsx");
|
||||
assert!(discovery.failures[0].error.as_ref().unwrap().contains("file limit"));
|
||||
}
|
||||
|
||||
fn at(a1: &str) -> CellRef {
|
||||
CellRef::parse(a1).unwrap()
|
||||
}
|
||||
@@ -1600,6 +1670,15 @@ mod tests {
|
||||
assert_eq!(package_of(&source), ["lost relationship madeUp"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_lists_warning_messages() {
|
||||
let with = FileReport { path: "a.ods".into(), warnings: vec!["ODS data-only import".into()], ..Default::default() };
|
||||
let without = FileReport { path: "b.xlsx".into(), ..Default::default() };
|
||||
let v = report_json(&[with, without], &Options::default());
|
||||
assert_eq!(v["files"][0]["warnings"], json!(["ODS data-only import"]));
|
||||
assert_eq!(v["files"][1]["warnings"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unopenable_files_are_reported() {
|
||||
let r = check_bytes("broken.xlsx", b"PK\x03\x04 not really", &Options::default());
|
||||
|
||||
@@ -36,13 +36,15 @@ use serde_json::{Value, json};
|
||||
|
||||
const USAGE: &str = "usage:
|
||||
gridcraft-cli info <file> [--json] sheets, used ranges, tables, charts, names
|
||||
gridcraft-cli convert <in> <out> [--sheet NAME] xlsx/csv/tsv/json/html by extension
|
||||
gridcraft-cli convert <in> <out> [--sheet NAME] xlsx/csv/tsv/json/html/pdf by extension
|
||||
gridcraft-cli eval <formula> [--in FILE] [--sheet S] [--cell A1] [--json]
|
||||
gridcraft-cli cat <file> [--range A1:D20] [--sheet S] [--formulas] [--csv]
|
||||
gridcraft-cli check <file-or-dir>... [--json] [--tolerance 1e-9] [--max-report N]
|
||||
cached-value calc oracle + XLSX round-trip fidelity (exit 1 on any difference)
|
||||
gridcraft-cli run [--in FILE | --sample NAME] [--cmd 'id={json}']... [--script FILE.jsonl]
|
||||
[--out FILE] [--print RANGE] [--quiet]
|
||||
a script line is id={json} or {\"command\": \"id\", \"params\": {…}};
|
||||
blank lines and lines starting with # or // are skipped
|
||||
gridcraft-cli commands [--json] [--search X]
|
||||
gridcraft-cli functions [--json] [--search X] [--category C]
|
||||
gridcraft-cli mcp [--connect PORT] [--in FILE | --sample NAME]
|
||||
@@ -271,11 +273,17 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
let input = a.pos(0, "<in>")?;
|
||||
let out = a.pos(1, "<out>")?;
|
||||
a.no_extra(2)?;
|
||||
if gridcraft_engine::io::FileKind::from_path(out).is_none() {
|
||||
return Err(format!("{out}: unknown output format (use .xlsx, .csv, .tsv, .json or .html)"));
|
||||
let pdf = out.to_ascii_lowercase().ends_with(".pdf");
|
||||
if !pdf && gridcraft_engine::io::FileKind::from_path(out).is_none() {
|
||||
return Err(format!("{out}: unknown output format (use .xlsx, .csv, .tsv, .json, .html or .pdf)"));
|
||||
}
|
||||
let mut s = open(input)?;
|
||||
activate_sheet(&mut s, a.opt("sheet"))?;
|
||||
if pdf {
|
||||
let result = exec(&mut s, "file.exportPdf", json!({"path": out}))?;
|
||||
eprintln!("wrote {out} ({} bytes, {} pages)", result["bytes"], result["pages"]);
|
||||
return Ok(());
|
||||
}
|
||||
let d = s.doc().map_err(|e| e.to_string())?;
|
||||
let bytes = gridcraft_engine::io::save_file_bytes(&d.wb, out).map_err(|e| e.to_string())?;
|
||||
gridcraft_engine::io::write_file(out, &bytes).map_err(|e| e.to_string())?;
|
||||
@@ -369,6 +377,17 @@ fn parse_command(text: &str) -> Result<Option<(String, Value)>, String> {
|
||||
}
|
||||
if t.starts_with('{') {
|
||||
let v: Value = serde_json::from_str(t).map_err(|e| format!("bad JSON: {e}"))?;
|
||||
// A key this does not read is a mistake, not a comment: `{"command": …, "args": {…}}` and
|
||||
// `{"command": …, "name": "Report"}` ran the command with no parameters at all and the
|
||||
// script still exited 0, so a typo made a valid, wrong workbook (#2256).
|
||||
if let Some(o) = v.as_object() {
|
||||
let mut unknown: Vec<&str> = o.keys().map(String::as_str).filter(|k| !matches!(*k, "command" | "id" | "params")).collect();
|
||||
if !unknown.is_empty() {
|
||||
unknown.sort_unstable();
|
||||
let (plural, list) = (if unknown.len() == 1 { "key" } else { "keys" }, unknown.join("`, `"));
|
||||
return Err(format!("unknown {plural} `{list}`: a line is {{\"command\": \"id\", \"params\": {{…}}}}"));
|
||||
}
|
||||
}
|
||||
let id = v.get("command").or_else(|| v.get("id")).and_then(Value::as_str).ok_or("JSON command needs a `command` field")?;
|
||||
return Ok(Some((id.to_string(), v.get("params").cloned().unwrap_or_else(|| json!({})))));
|
||||
}
|
||||
@@ -561,6 +580,26 @@ mod tests {
|
||||
assert_eq!(parse_command("{\"command\":\"edit.redo\"}").unwrap(), Some(("edit.redo".into(), json!({}))));
|
||||
assert_eq!(parse_command(" # comment").unwrap(), None);
|
||||
assert!(parse_command("x={bad").is_err());
|
||||
// A key this does not read is a mistake: the command used to run with no parameters and
|
||||
// the script still exited 0, so a typo made a valid, wrong workbook (#2256).
|
||||
for (line, named) in [
|
||||
(r#"{"command":"home.insertSheet","args":{"name":"R"}}"#, "`args`"),
|
||||
(r#"{"command":"home.insertSheet","name":"R"}"#, "`name`"),
|
||||
(r#"{"command":"home.insertSheet","params":{},"note":"why"}"#, "`note`"),
|
||||
] {
|
||||
let e = parse_command(line).expect_err("refused");
|
||||
assert!(e.contains("unknown key") && e.contains(named), "{line}: {e}");
|
||||
assert!(e.contains(r#"{"command": "id", "params": {…}}"#), "{line}: {e}");
|
||||
}
|
||||
// Several at once are listed in one message, sorted.
|
||||
let e = parse_command(r#"{"command":"x","name":"R","args":{}}"#).expect_err("refused");
|
||||
assert!(e.contains("unknown keys `args`, `name`"), "{e}");
|
||||
// `id` is still the accepted alternative to `command`, and `params` still carries them.
|
||||
assert_eq!(parse_command(r#"{"id":"edit.undo"}"#).unwrap(), Some(("edit.undo".into(), json!({}))));
|
||||
assert_eq!(
|
||||
parse_command(r#"{"command":"home.insertSheet","params":{"name":"R"}}"#).unwrap(),
|
||||
Some(("home.insertSheet".into(), json!({"name": "R"})))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -583,4 +622,21 @@ mod tests {
|
||||
assert!(Args::parse(&["--nope".to_string()], &[], &[]).is_err());
|
||||
assert!(Args::parse(&["--range".to_string()], &["range"], &[]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_xlsx_to_pdf_keeps_links() {
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-cli-pdf-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let input = dir.join("links.xlsx");
|
||||
let output = dir.join("links.pdf");
|
||||
let mut s = blank();
|
||||
s.execute("insert.link", json!({"cell": "A1", "target": "https://example.org"})).unwrap();
|
||||
let bytes = gridcraft_engine::io::save_file_bytes(&s.doc().unwrap().wb, input.to_str().unwrap()).unwrap();
|
||||
gridcraft_engine::io::write_file(input.to_str().unwrap(), &bytes).unwrap();
|
||||
convert(&[input.to_string_lossy().into_owned(), output.to_string_lossy().into_owned()]).unwrap();
|
||||
let pdf = std::fs::read(&output).unwrap();
|
||||
assert!(pdf.starts_with(b"%PDF-"));
|
||||
assert!(pdf.windows(b"/URI (https://example.org)".len()).any(|w| w == b"/URI (https://example.org)"));
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Output, Stdio};
|
||||
@@ -174,3 +172,73 @@ fn check_exit_codes_and_json() {
|
||||
assert_eq!(run(&["check", book_s, "--tolerance", "x"]).status.code(), Some(2));
|
||||
assert_eq!(run(&["check", book_s, "--bogus"]).status.code(), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_rejects_an_empty_scan() {
|
||||
let dir = tmpdir("check-empty");
|
||||
std::fs::write(dir.join("notes.txt"), "not a workbook").unwrap();
|
||||
std::fs::write(dir.join(".hidden.xlsx"), "ignored").unwrap();
|
||||
std::fs::write(dir.join("~$locked.xlsx"), "ignored").unwrap();
|
||||
let out = run(&["check", dir.to_str().unwrap(), "--json"]);
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert!(report["files"][0]["error"].as_str().unwrap().contains("no workbook files"));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_reports_a_depth_limited_scan_while_checking_readable_files() {
|
||||
let dir = tmpdir("check-depth");
|
||||
let book = dir.join("readable.xlsx");
|
||||
ok(&["run", "--out", book.to_str().unwrap(), "--quiet"]);
|
||||
let mut deep = dir.clone();
|
||||
for _ in 0..34 {
|
||||
deep.push("d");
|
||||
}
|
||||
std::fs::create_dir_all(&deep).unwrap();
|
||||
std::fs::write(deep.join("skipped.xlsx"), "not reached").unwrap();
|
||||
let out = run(&["check", dir.to_str().unwrap(), "--json"]);
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert_eq!(report["totals"]["opened"], 1);
|
||||
assert!(report["files"].as_array().unwrap().iter().any(|r| r["error"].as_str().is_some_and(|e| e.contains("depth limit"))));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// A script line whose parameters are under a key `run` does not read used to run the command
|
||||
/// with no parameters at all, and the run still exited 0: three mistaken lines made Sheet2,
|
||||
/// Sheet3 and Sheet4 instead of the named sheets, and nothing said so (#2256).
|
||||
#[test]
|
||||
fn a_script_line_with_an_unknown_key_stops_the_run() {
|
||||
let dir = tmpdir("script-keys");
|
||||
let script = dir.join("s.jsonl");
|
||||
let out = dir.join("t.xlsx");
|
||||
let write = |lines: &[&str]| std::fs::write(&script, lines.join("\n")).unwrap();
|
||||
|
||||
write(&[r#"{"command":"file.new"}"#, r#"{"command":"home.insertSheet","args":{"name":"Report"}}"#]);
|
||||
let o = run(&["run", "--script", script.to_str().unwrap(), "--out", out.to_str().unwrap()]);
|
||||
let err = String::from_utf8_lossy(&o.stderr);
|
||||
assert!(!o.status.success(), "the run must stop: {err}");
|
||||
assert!(err.contains("s.jsonl:2"), "it names the line: {err}");
|
||||
assert!(err.contains("unknown key `args`"), "and the key: {err}");
|
||||
assert!(!out.exists(), "and writes no workbook: {err}");
|
||||
|
||||
// The forms that are read still work, in one script: `params`, `id=json`, and a comment.
|
||||
write(&[
|
||||
r#"{"command":"file.new"}"#,
|
||||
r#"{"command":"home.insertSheet","params":{"name":"Report"}}"#,
|
||||
"# a comment",
|
||||
r#"home.insertSheet={"name":"Report2"}"#,
|
||||
]);
|
||||
let o = run(&["run", "--script", script.to_str().unwrap(), "--out", out.to_str().unwrap()]);
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let info = ok(&["info", out.to_str().unwrap()]);
|
||||
assert!(info.contains("Report:") && info.contains("Report2:"), "the sheets are named: {info}");
|
||||
assert!(!info.contains("Sheet2"), "and no sheet was added with a default name: {info}");
|
||||
|
||||
// The usage text says what a line looks like, which is how this was found.
|
||||
let usage = String::from_utf8_lossy(&run(&[]).stderr).into_owned() + &String::from_utf8_lossy(&run(&["--help"]).stdout);
|
||||
assert!(usage.contains(r#"{"command": "id", "params": {…}}"#), "{usage}");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! The crate's integration tests, one module per file, built as one test binary.
|
||||
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod cli;
|
||||
mod text_to_columns_quotes;
|
||||
@@ -26,4 +26,4 @@ rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "UrlSearchParams"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "UrlSearchParams", "AddEventListenerOptions", "Storage"] }
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>GridCraft</title>
|
||||
<link data-trunk rel="icon" href="../../assets/app-icon/gridcraft.ico" />
|
||||
<link data-trunk rel="rust" data-bin="gridcraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
|
||||
@@ -11,11 +11,14 @@
|
||||
//! URL query flags: `?webgl` forces WebGL2; `?sample=sales|budget|grades` opens a sample;
|
||||
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
|
||||
//! workbook to open and stores what GridCraft saves (`host`), and `&author=` names the user for
|
||||
//! notes and comments.
|
||||
//! notes and comments. Preferences (theme, interface size, editing options…) are kept in
|
||||
//! `localStorage` (`prefs`).
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod host;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod prefs;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
//! Preferences between visits (#1562): what the desktop app keeps in `ui.json` (theme, interface
|
||||
//! size, language, ribbon state…) and `prefs.json` (editing options, user name…), kept in the
|
||||
//! browser's `localStorage` under [`UI_KEY`] and [`PREFS_KEY`]. Restored at startup; written back
|
||||
//! whenever they change, since a reload or a closed tab runs no exit code.
|
||||
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
|
||||
/// The `localStorage` key for the desktop's `ui.json`.
|
||||
pub const UI_KEY: &str = "gridcraft.ui";
|
||||
/// The `localStorage` key for the desktop's `prefs.json`.
|
||||
pub const PREFS_KEY: &str = "gridcraft.prefs";
|
||||
/// Largest stored value read back (`localStorage` holds about 5 MB per site).
|
||||
const MAX_STORED: usize = 1 << 20;
|
||||
|
||||
/// Apply preferences stored on an earlier visit, as the desktop app reads `ui.json` and
|
||||
/// `prefs.json`. Missing, oversized or unreadable values leave what the app starts with.
|
||||
pub fn restore(app: &mut SheetApp, ui: Option<&str>, prefs: Option<&str>) {
|
||||
let readable = |s: &&str| s.len() <= MAX_STORED;
|
||||
if let Some(s) = ui.filter(readable)
|
||||
&& let Ok(json) = serde_json::from_str::<serde_json::Value>(s)
|
||||
&& let Ok(state) = serde_json::from_value::<gridcraft_ui_egui::UiState>(json.clone())
|
||||
{
|
||||
// No usable saved language: keep the browser's, as the desktop keeps the system's.
|
||||
let language = gridcraft_ui_egui::i18n::saved_language(&json).unwrap_or(app.ui.language);
|
||||
app.ui = state;
|
||||
app.ui.language = language;
|
||||
}
|
||||
if let Some(s) = prefs.filter(readable)
|
||||
&& let Ok(p) = serde_json::from_str(s)
|
||||
{
|
||||
app.session.prefs = p;
|
||||
}
|
||||
}
|
||||
|
||||
/// Tracks what was last written, so the store is written only when a preference changed.
|
||||
#[derive(Default)]
|
||||
pub struct PrefsSync {
|
||||
ui: Option<String>,
|
||||
prefs: Option<String>,
|
||||
/// The user name to store in place of the session's (see [`Self::keep_user_name`]).
|
||||
user_name: Option<String>,
|
||||
}
|
||||
|
||||
impl PrefsSync {
|
||||
/// Starts from what is already stored, so restoring doesn't write it straight back.
|
||||
pub fn new(ui: Option<String>, prefs: Option<String>) -> Self {
|
||||
Self { ui, prefs, user_name: None }
|
||||
}
|
||||
|
||||
/// Stores `name` as the user name whatever the session's becomes: under `?host=parent` the
|
||||
/// framing page names the author for this visit only, and must not replace the user's own.
|
||||
pub fn keep_user_name(&mut self, name: String) {
|
||||
self.user_name = Some(name);
|
||||
}
|
||||
|
||||
/// The `(key, JSON)` pairs to store because they differ from what was last written.
|
||||
pub fn changed(&mut self, app: &SheetApp) -> Vec<(&'static str, String)> {
|
||||
let mut out = Vec::new();
|
||||
let prefs = match &self.user_name {
|
||||
Some(name) => serde_json::to_string(&gridcraft_engine::Prefs { user_name: name.clone(), ..app.session.prefs.clone() }),
|
||||
None => serde_json::to_string(&app.session.prefs),
|
||||
};
|
||||
for (key, last, json) in [(UI_KEY, &mut self.ui, serde_json::to_string(&app.ui)), (PREFS_KEY, &mut self.prefs, prefs)] {
|
||||
match json {
|
||||
Ok(json) if last.as_deref() != Some(json.as_str()) => {
|
||||
*last = Some(json.clone());
|
||||
out.push((key, json));
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => log::warn!("preferences: cannot serialize {key}: {e}"),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::Services;
|
||||
use gridcraft_ui_egui::i18n::Language;
|
||||
use serde_json::json;
|
||||
|
||||
fn app() -> SheetApp {
|
||||
let mut app = SheetApp::new(Session::new(), Services::default());
|
||||
// As `web::start` sets it up before restoring.
|
||||
app.ui.system_theme = true;
|
||||
app.ui.language = Language::De;
|
||||
app
|
||||
}
|
||||
|
||||
fn stored(writes: &[(&'static str, String)], key: &str) -> Option<String> {
|
||||
writes.iter().find(|(k, _)| *k == key).map(|(_, v)| v.clone())
|
||||
}
|
||||
|
||||
/// #1562: dark mode chosen on one visit is there after a reload, with the editing options.
|
||||
#[test]
|
||||
fn dark_mode_survives_reload() {
|
||||
let mut first = app();
|
||||
let mut sync = PrefsSync::new(None, None);
|
||||
assert_eq!(sync.changed(&first).len(), 2, "first frame stores the preferences");
|
||||
assert!(sync.changed(&first).is_empty(), "unchanged preferences are not rewritten");
|
||||
first.run("view.theme", json!({"mode": "dark"})).unwrap();
|
||||
first.session.prefs.enter_direction = "right".into();
|
||||
let writes = sync.changed(&first);
|
||||
let (ui, prefs) = (stored(&writes, UI_KEY), stored(&writes, PREFS_KEY));
|
||||
assert!(ui.is_some() && prefs.is_some(), "both changes are stored: {writes:?}");
|
||||
|
||||
let mut reloaded = app();
|
||||
restore(&mut reloaded, ui.as_deref(), prefs.as_deref());
|
||||
assert!(reloaded.ui.dark && !reloaded.ui.system_theme, "dark mode is back");
|
||||
assert_eq!(reloaded.session.prefs.enter_direction, "right");
|
||||
assert!(PrefsSync::new(ui, prefs).changed(&reloaded).is_empty(), "restoring doesn't write back");
|
||||
}
|
||||
|
||||
/// `?host=parent`: the author the framing page names is not stored over the user's own name.
|
||||
#[test]
|
||||
fn host_author_is_not_stored() {
|
||||
let mut first = app();
|
||||
first.session.prefs.user_name = "Ada".into();
|
||||
let mut sync = PrefsSync::new(None, None);
|
||||
let stored_prefs = stored(&sync.changed(&first), PREFS_KEY);
|
||||
|
||||
let mut hosted = app();
|
||||
restore(&mut hosted, None, stored_prefs.as_deref());
|
||||
let mut sync = PrefsSync::new(None, stored_prefs);
|
||||
sync.keep_user_name(hosted.session.prefs.user_name.clone());
|
||||
hosted.session.prefs.user_name = "Host Author".into();
|
||||
hosted.session.prefs.r1c1 = true;
|
||||
let writes = sync.changed(&hosted);
|
||||
let prefs = stored(&writes, PREFS_KEY).expect("other preferences are still stored");
|
||||
|
||||
let mut reloaded = app();
|
||||
restore(&mut reloaded, None, Some(&prefs));
|
||||
assert_eq!(reloaded.session.prefs.user_name, "Ada");
|
||||
assert!(reloaded.session.prefs.r1c1);
|
||||
}
|
||||
|
||||
/// A stored `ui` without a language keeps the browser's, as the desktop keeps the system's.
|
||||
#[test]
|
||||
fn missing_language_keeps_the_browsers() {
|
||||
let mut a = app();
|
||||
restore(&mut a, Some(r#"{"dark":true}"#), None);
|
||||
assert!(a.ui.dark);
|
||||
assert_eq!(a.ui.language, Language::De);
|
||||
restore(&mut a, Some(r#"{"language":"fr"}"#), None);
|
||||
assert_eq!(a.ui.language, Language::Fr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_storage_keeps_defaults() {
|
||||
let big = format!("{{\"dark\":true,\"recent\":[\"{}\"]}}", "x".repeat(MAX_STORED));
|
||||
for junk in [None, Some(""), Some("not json"), Some("[1,2]"), Some("{\"dark\":42}"), Some(big.as_str())] {
|
||||
let mut a = app();
|
||||
restore(&mut a, junk, junk);
|
||||
assert!(a.ui.system_theme && !a.ui.dark, "{junk:?}");
|
||||
assert_eq!(a.ui.language, Language::De);
|
||||
assert_eq!(a.session.prefs.enter_direction, "down");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,17 @@ pub fn start() {
|
||||
return;
|
||||
};
|
||||
let mut options = eframe::WebOptions::default();
|
||||
if let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut options.wgpu_options.wgpu_setup {
|
||||
let default_descriptor = std::sync::Arc::clone(&setup.device_descriptor);
|
||||
|
||||
setup.device_descriptor = std::sync::Arc::new(move |adapter| {
|
||||
let mut descriptor = default_descriptor(adapter);
|
||||
|
||||
descriptor.required_limits = descriptor.required_limits.or_worse_values_from(&adapter.limits());
|
||||
|
||||
descriptor
|
||||
});
|
||||
}
|
||||
if query().contains("webgl")
|
||||
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
|
||||
{
|
||||
@@ -58,14 +69,20 @@ pub fn start() {
|
||||
) {
|
||||
app.ui.language = browser;
|
||||
}
|
||||
// Preferences from an earlier visit (#1562), as the desktop app reads `ui.json`
|
||||
// and `prefs.json`; a saved language replaces the browser's.
|
||||
let (stored_ui, stored_prefs) = (stored(crate::prefs::UI_KEY), stored(crate::prefs::PREFS_KEY));
|
||||
crate::prefs::restore(&mut app, stored_ui.as_deref(), stored_prefs.as_deref());
|
||||
let mut prefs = crate::prefs::PrefsSync::new(stored_ui, stored_prefs);
|
||||
SheetApp::setup_context_for_language(&cc.egui_ctx, false, app.ui.language);
|
||||
if let Some(bridge) = &app_bridge {
|
||||
prefs.keep_user_name(app.session.prefs.user_name.clone());
|
||||
if let Some(author) = query_param("author") {
|
||||
crate::host::Bridge::set_author(&mut app, &author);
|
||||
}
|
||||
bridge.post_ready();
|
||||
}
|
||||
Ok(Box::new(WebShell { app, inbox, bridge: app_bridge }))
|
||||
Ok(Box::new(WebShell { app, inbox, bridge: app_bridge, prefs }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -93,6 +110,16 @@ struct WebShell {
|
||||
app: SheetApp,
|
||||
inbox: Inbox,
|
||||
bridge: Option<crate::host::Bridge>,
|
||||
prefs: crate::prefs::PrefsSync,
|
||||
}
|
||||
|
||||
fn local_storage() -> Option<web_sys::Storage> {
|
||||
web_sys::window()?.local_storage().ok()?
|
||||
}
|
||||
|
||||
/// A value stored on an earlier visit, if the browser allows storage.
|
||||
fn stored(key: &str) -> Option<String> {
|
||||
local_storage()?.get_item(key).ok()?
|
||||
}
|
||||
|
||||
impl eframe::App for WebShell {
|
||||
@@ -113,6 +140,20 @@ impl eframe::App for WebShell {
|
||||
});
|
||||
}
|
||||
self.app.logic(ctx);
|
||||
// Store changed preferences straight away: a reload or closed tab runs no exit code (#1562).
|
||||
let changed = self.prefs.changed(&self.app);
|
||||
if !changed.is_empty() {
|
||||
match local_storage() {
|
||||
Some(store) => {
|
||||
for (key, json) in changed {
|
||||
if let Err(e) = store.set_item(key, &json) {
|
||||
log::warn!("preferences: cannot save {key}: {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
None => log::warn!("preferences: browser storage is unavailable"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
@@ -224,15 +265,20 @@ fn print_pdf(bytes: &[u8]) -> Result<(), String> {
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let document = window.document().ok_or("no document")?;
|
||||
|
||||
let iframe: web_sys::HtmlIFrameElement = document.create_element("iframe").map_err(js)?.dyn_into().map_err(|_| "not an iframe")?;
|
||||
iframe.style().set_property("display", "none").map_err(js)?;
|
||||
let body = document.body().ok_or("no body")?;
|
||||
|
||||
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
|
||||
let opts = web_sys::BlobPropertyBag::new();
|
||||
opts.set_type("application/pdf");
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
|
||||
|
||||
let iframe: web_sys::HtmlIFrameElement = document.create_element("iframe").map_err(js)?.dyn_into().map_err(|_| "not an iframe")?;
|
||||
iframe.style().set_property("display", "none").map_err(js)?;
|
||||
document.body().ok_or("no body")?.append_child(&iframe).map_err(js)?;
|
||||
if let Err(e) = body.append_child(&iframe) {
|
||||
cleanup_print(&iframe, &url);
|
||||
return Err(js(e));
|
||||
}
|
||||
|
||||
// `onload` fires once the PDF has actually rendered inside the iframe;
|
||||
// printing before that would show a blank page. Set the handler BEFORE
|
||||
@@ -240,22 +286,44 @@ fn print_pdf(bytes: &[u8]) -> Result<(), String> {
|
||||
let iframe_for_load = iframe.clone();
|
||||
let url_for_cleanup = url.clone();
|
||||
let on_load = wasm_bindgen::closure::Closure::once_into_js(move || {
|
||||
let Some(content_window) = iframe_for_load.content_window() else { return };
|
||||
let _ = content_window.print();
|
||||
// `afterprint` fires once the print dialog (or print preview) closes, whether the user
|
||||
// printed or cancelled — the right moment to release the blob URL and drop the iframe.
|
||||
let iframe_for_cleanup = iframe_for_load.clone();
|
||||
let on_after_print = wasm_bindgen::closure::Closure::once_into_js(move || {
|
||||
web_sys::Url::revoke_object_url(&url_for_cleanup).ok();
|
||||
iframe_for_cleanup.remove();
|
||||
});
|
||||
let _ = content_window.add_event_listener_with_callback("afterprint", on_after_print.unchecked_ref());
|
||||
iframe_for_load.set_onload(None);
|
||||
if let Err(e) = print_loaded_pdf(&iframe_for_load, &url_for_cleanup) {
|
||||
cleanup_print(&iframe_for_load, &url_for_cleanup);
|
||||
log::error!("print failed: {e:?}");
|
||||
}
|
||||
});
|
||||
iframe.set_onload(Some(on_load.unchecked_ref()));
|
||||
iframe.set_src(&url);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cleanup_print(iframe: &web_sys::HtmlIFrameElement, url: &str) {
|
||||
// Both operations are idempotent: print can fail after dispatching `afterprint`.
|
||||
web_sys::Url::revoke_object_url(url).ok();
|
||||
iframe.remove();
|
||||
}
|
||||
|
||||
fn print_loaded_pdf(iframe: &web_sys::HtmlIFrameElement, url: &str) -> Result<(), JsValue> {
|
||||
let content_window = iframe.content_window().ok_or_else(|| JsValue::from_str("no print content window"))?;
|
||||
let iframe_for_cleanup = iframe.clone();
|
||||
let url_for_cleanup = url.to_owned();
|
||||
let on_after_print = wasm_bindgen::closure::Closure::once(move || {
|
||||
cleanup_print(&iframe_for_cleanup, &url_for_cleanup);
|
||||
});
|
||||
// Listen before `print()`, which can dispatch `afterprint` before it returns.
|
||||
// A one-shot listener also releases the callback when the dialog closes or is cancelled.
|
||||
let options = web_sys::AddEventListenerOptions::new();
|
||||
options.set_once(true);
|
||||
let callback = on_after_print.as_ref().unchecked_ref();
|
||||
content_window.add_event_listener_with_callback_and_add_event_listener_options("afterprint", callback, &options)?;
|
||||
if let Err(e) = content_window.print() {
|
||||
let _ = content_window.remove_event_listener_with_callback("afterprint", callback);
|
||||
return Err(e);
|
||||
}
|
||||
let _ = on_after_print.into_js_value();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mime_for(name: &str) -> &'static str {
|
||||
match name.rsplit('.').next().map(str::to_ascii_lowercase).as_deref() {
|
||||
Some("xlsx") => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
@@ -274,7 +342,6 @@ fn mime_for(name: &str) -> &'static str {
|
||||
}
|
||||
|
||||
/// Trigger a browser save dialog or download of `bytes` named after `name`.
|
||||
|
||||
fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
|
||||
@@ -18,6 +18,7 @@ egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
image = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for gridcraft.exe; build.rs only uses it when the target is Windows.
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
//! Localhost JSON-lines control server. Each connection may send many requests; each line
|
||||
//! `{"id":…, "method":…, "params":…}` gets one reply line `{"id":…, "ok":…, …}`.
|
||||
//!
|
||||
//! The server only reads requests: a line that is not a JSON object with a string `method` (an
|
||||
//! HTTP request line, a stray `nc`, binary data) gets one error reply and the connection is
|
||||
//! closed, so nothing sent after it runs.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
@@ -13,6 +17,13 @@ const MAX_LINE: usize = 64 * 1024 * 1024;
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
let listener = TcpListener::bind(("127.0.0.1", port))?;
|
||||
let rx = serve_listener(listener, ctx)?;
|
||||
log::info!("control channel listening on 127.0.0.1:{port}");
|
||||
Ok(rx)
|
||||
}
|
||||
|
||||
/// Accept connections on `listener`; requests arrive on the returned receiver.
|
||||
fn serve_listener(listener: TcpListener, ctx: egui::Context) -> std::io::Result<Receiver<ControlRequest>> {
|
||||
let (tx, rx) = channel();
|
||||
std::thread::Builder::new().name("control".into()).spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
@@ -21,47 +32,193 @@ pub fn start(port: u16, ctx: egui::Context) -> std::io::Result<Receiver<ControlR
|
||||
let _ = std::thread::Builder::new().name("control-conn".into()).spawn(move || serve(stream, tx, ctx));
|
||||
}
|
||||
})?;
|
||||
log::info!("control channel listening on 127.0.0.1:{port}");
|
||||
Ok(rx)
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
buf.clear();
|
||||
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
|
||||
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
|
||||
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
|
||||
if n == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
if buf.last() == Some(&b'\n') {
|
||||
buf.pop();
|
||||
return Ok(true);
|
||||
}
|
||||
if buf.len() > max {
|
||||
return Err(format!("request too large (over {max} bytes); closing the connection"));
|
||||
}
|
||||
// Last line without a newline, then end of stream.
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// One request line, parsed.
|
||||
enum Line {
|
||||
Request {
|
||||
id: Json,
|
||||
method: String,
|
||||
params: Json,
|
||||
},
|
||||
Blank,
|
||||
/// Not a request: reply with this error and close the connection.
|
||||
Reject(String),
|
||||
}
|
||||
|
||||
fn parse_line(bytes: &[u8]) -> Line {
|
||||
let Ok(text) = std::str::from_utf8(bytes) else {
|
||||
return Line::Reject("not a control request (invalid UTF-8); closing the connection".into());
|
||||
};
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Line::Blank;
|
||||
}
|
||||
if is_http_request_line(text) {
|
||||
return Line::Reject("HTTP is not served here: this port takes JSON lines; closing the connection".into());
|
||||
}
|
||||
let msg = match serde_json::from_str::<Json>(text) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Line::Reject(format!("invalid JSON ({e}); expected one JSON request object per line; closing the connection")),
|
||||
};
|
||||
let Some(obj) = msg.as_object() else {
|
||||
return Line::Reject("not a control request (expected a JSON object); closing the connection".into());
|
||||
};
|
||||
let Some(method) = obj.get("method").and_then(Json::as_str) else {
|
||||
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
|
||||
};
|
||||
Line::Request {
|
||||
id: obj.get("id").cloned().unwrap_or(Json::Null),
|
||||
method: method.to_string(),
|
||||
params: obj.get("params").cloned().unwrap_or(json!({})),
|
||||
}
|
||||
}
|
||||
|
||||
/// An HTTP/1 request line (`POST / HTTP/1.1`) or the HTTP/2 preface (`PRI * HTTP/2.0`).
|
||||
fn is_http_request_line(text: &str) -> bool {
|
||||
const METHODS: [&str; 10] = ["GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH", "PRI"];
|
||||
text.split_once(' ').is_some_and(|(method, _)| METHODS.contains(&method))
|
||||
|| text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/"))
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
let reader = BufReader::new(read);
|
||||
for line in reader.lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
if line.len() > MAX_LINE {
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": "request too large"}));
|
||||
continue;
|
||||
}
|
||||
let msg: Json = match serde_json::from_str(&line) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": format!("invalid JSON: {e}")}));
|
||||
continue;
|
||||
}
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
|
||||
Ok(false) => break,
|
||||
// The peer is gone before sending a line: nobody to tell.
|
||||
Err(_) if buf.is_empty() => break,
|
||||
Err(e) => e,
|
||||
Ok(true) => match parse_line(&buf) {
|
||||
Line::Blank => continue,
|
||||
Line::Reject(e) => e,
|
||||
Line::Request { id, method, params } => {
|
||||
let (req, rx) = ControlRequest::new(method, params);
|
||||
let state = std::sync::Arc::clone(&req.state);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
// Wake the UI thread: egui only runs frames when something happens.
|
||||
ctx.request_repaint();
|
||||
let mut reply = rx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| {
|
||||
// Give up on the request as well as on its reply. While it is still
|
||||
// queued this keeps it from running late -- the client was told it had
|
||||
// failed, and an agent that retried after the error ran it twice (#1914);
|
||||
// if the app had already started it, its outcome is unknown instead.
|
||||
if ControlRequest::abandon(&state) {
|
||||
json!({"ok": false, "error": "timed out waiting for the UI (is the window minimised?)"})
|
||||
} else {
|
||||
json!({"ok": false, "error": "timed out waiting for the UI, which had already started the request, so it may still finish; its outcome is unknown"})
|
||||
}
|
||||
});
|
||||
if let Some(o) = reply.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
},
|
||||
};
|
||||
let id = msg.get("id").cloned().unwrap_or(Json::Null);
|
||||
let method = msg.get("method").and_then(Json::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
let (req, rx) = ControlRequest::new(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
// The connection closes either way; a peer that can't read the reason loses nothing.
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": reject}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Send `input` on a new connection and read reply lines until the server closes it.
|
||||
fn exchange(addr: std::net::SocketAddr, input: &[u8]) -> Vec<Json> {
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(input).unwrap();
|
||||
// A read timeout ends the list too: the server kept the connection open.
|
||||
BufReader::new(c).lines().map_while(Result::ok).map(|l| serde_json::from_str(&l).unwrap()).collect()
|
||||
}
|
||||
|
||||
fn serve_on_loopback() -> (std::net::SocketAddr, Receiver<ControlRequest>) {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
(addr, serve_listener(listener, egui::Context::default()).unwrap())
|
||||
}
|
||||
|
||||
/// A browser can POST to the port: its request line closes the connection before the JSON
|
||||
/// line in its body is read, so nothing is dispatched.
|
||||
#[test]
|
||||
fn an_http_request_closes_the_connection_before_its_body_is_dispatched() {
|
||||
let (addr, rx) = serve_on_loopback();
|
||||
let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1:7979\r\nContent-Type: text/plain\r\n\r\n\
|
||||
{\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"cell.set\",\"params\":{\"cell\":\"A1\",\"input\":\"x\"}}}\n";
|
||||
let replies = exchange(addr, http);
|
||||
assert_eq!(replies.len(), 1, "{replies:?}");
|
||||
assert!(replies[0]["error"].as_str().unwrap().contains("closing the connection"), "{replies:?}");
|
||||
assert!(rx.try_recv().is_err(), "the body was dispatched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_that_is_not_a_request_closes_the_connection() {
|
||||
let (addr, rx) = serve_on_loopback();
|
||||
std::thread::spawn(move || {
|
||||
for req in rx {
|
||||
let _ = req.reply.send(json!({"ok": true}));
|
||||
}
|
||||
});
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(b"{\"id\":1,\"method\":\"a\"}\r\n\n{\"id\":2,\"method\":\"b\"}\n").unwrap();
|
||||
let mut r = BufReader::new(c);
|
||||
for id in [1, 2] {
|
||||
let mut line = String::new();
|
||||
r.read_line(&mut line).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Json>(&line).unwrap()["id"], id, "{line}");
|
||||
}
|
||||
// Wake the UI thread: egui only runs frames when something happens.
|
||||
ctx.request_repaint();
|
||||
let mut reply = rx
|
||||
.recv_timeout(Duration::from_secs(60))
|
||||
.unwrap_or_else(|_| json!({"ok": false, "error": "timed out waiting for the UI (is the window minimised?)"}));
|
||||
if let Some(o) = reply.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
for junk in [&b"hello\n"[..], b"[1,2]\n", b"{\"id\":1}\n", b"{\"method\":5}\n", b"\xff\xfe\n", b"GET /x HTTP/1.1\n"] {
|
||||
let mut input = junk.to_vec();
|
||||
input.extend_from_slice(b"{\"id\":2,\"method\":\"b\"}\n");
|
||||
let replies = exchange(addr, &input);
|
||||
assert_eq!(replies.len(), 1, "{replies:?}");
|
||||
assert_eq!(replies[0]["ok"], false);
|
||||
}
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
break;
|
||||
}
|
||||
assert!(is_http_request_line("OPTIONS * HTTP/1.1"));
|
||||
assert!(is_http_request_line("PRI * HTTP/2.0"));
|
||||
assert!(!is_http_request_line("{\"method\":\"GET /\"}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_overlong_line_is_refused_without_reading_the_rest() {
|
||||
let mut buf = Vec::new();
|
||||
let long = [b'x'; 100];
|
||||
let mut input: &[u8] = &long;
|
||||
assert!(read_bounded_line(&mut input, &mut buf, 10).is_err());
|
||||
assert_eq!(input.len(), 100 - 11, "the rest of the line is left unread");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,29 +92,35 @@ fn matches(name: &str, target: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch; a clock
|
||||
/// set absurdly far ahead keeps its real year (years far outside chrono's own range are preserved)
|
||||
/// instead of panicking.
|
||||
pub fn timestamp(t: SystemTime) -> String {
|
||||
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
||||
let secs = since.as_secs();
|
||||
let (days, rem) = (secs / 86_400, secs % 86_400);
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
|
||||
let (y, dt) = utc_parts(since.as_secs(), since.subsec_nanos());
|
||||
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
||||
format!("{y:04}-{}", dt.format("%m-%dT%H:%M:%S%.3fZ"))
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock
|
||||
/// value can overflow it.
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days.saturating_add(719_468);
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
|
||||
(y, m, d)
|
||||
/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
|
||||
/// chrono's range by whole 400-year cycles). The Gregorian calendar repeats every 400 years
|
||||
/// (146_097 days), so month, day and time of day come from chrono and only the year is carried,
|
||||
/// which keeps every input exact instead of collapsing out-of-range years to the epoch. `secs` is
|
||||
/// `u64` (the clock is clamped at the epoch before 1970), so `days` always fits an `i64`.
|
||||
fn utc_parts(secs: u64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
|
||||
use chrono::Datelike;
|
||||
let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64);
|
||||
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
|
||||
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
|
||||
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
|
||||
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
|
||||
match (date, time) {
|
||||
(Some(d), Some(t)) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t))
|
||||
}
|
||||
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
@@ -393,6 +399,17 @@ mod tests {
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
|
||||
// Before the epoch (a clock set wrong) clamps instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||
// A clock set absurdly far ahead (past chrono's own range) keeps its real year rather than
|
||||
// collapsing to the epoch. Windows' SystemTime can't hold such an instant, so it is built
|
||||
// with a checked add and only asserted where it exists.
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
|
||||
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
|
||||
}
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
|
||||
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
|
||||
}
|
||||
// Years past 9999 keep the plain zero-padded year (no `+`).
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -125,7 +125,7 @@ fn tree() -> Vec<(&'static str, Vec<Entry>)> {
|
||||
("Unhide Sheet…", "sheet.unhide", None),
|
||||
("-", "", None),
|
||||
("Conditional Formatting…", "dialog:manageRules", None),
|
||||
("Format as Table", "insert.table", None),
|
||||
("Format as Table", "home.formatAsTable", None),
|
||||
],
|
||||
),
|
||||
(
|
||||
|
||||
@@ -60,8 +60,39 @@ sync_github_name = true
|
||||
[people.jsymes21-byte]
|
||||
real_name = "James Symes"
|
||||
sync_github_name = true
|
||||
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
|
||||
[people.Shnrqpdr]
|
||||
sync_github_name = true
|
||||
|
||||
[people.yeniufuklar-com]
|
||||
real_name = "Ufuk Efe"
|
||||
display_name = "Ufuk Efe"
|
||||
sync_github_name = true
|
||||
|
||||
[people.pourie-he]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Stefanfra]
|
||||
real_name = "Stefan Fransen"
|
||||
sync_github_name = true
|
||||
|
||||
[people.allenyllee]
|
||||
sync_github_name = true
|
||||
|
||||
[people.marcoarnulfo]
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.Harrison-Booth]
|
||||
real_name = "Harrison Booth"
|
||||
sync_github_name = false
|
||||
|
||||
[people.bilalmughal]
|
||||
sync_github_name = true
|
||||
|
||||
[people.tyrel]
|
||||
real_name = "Tyrel Haveman"
|
||||
|
||||
+160
-52
@@ -13,10 +13,14 @@ use gridcraft_model::Workbook;
|
||||
|
||||
/// Largest array a reference may expand to.
|
||||
pub const MAX_CELLS: u64 = 16_000_000;
|
||||
/// References up to this many cells are read at their full size; larger ones (`A:A`, ranges
|
||||
/// reserved for data to come such as `$A$2:$A$50000`) stop at the sheet's used range, so that
|
||||
/// formulas over them stay fast.
|
||||
/// References up to this many cells (one whole row) are always read at their full size.
|
||||
const FULL_SIZE_CELLS: u64 = 16_384;
|
||||
/// Bounded references up to this many cells (one column's worth) are read at their full size too, as
|
||||
/// Excel does, so their shape lines up with the arrays next to them (`D3:AZ400*B3:B400` is 398 by
|
||||
/// 49 even when the data ends at column F) and their blanks count. References that run to the edge
|
||||
/// of the sheet (`A:A`, `A2:A1048576`, several whole rows) and larger blocks stop at the sheet's used
|
||||
/// range, so that formulas over them stay fast.
|
||||
const FULL_SIZE_BOUNDED_CELLS: u64 = 1_048_576;
|
||||
const MAX_DEPTH: usize = 400;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
@@ -90,6 +94,8 @@ struct FnCtx<'a, 'h> {
|
||||
ev: &'a mut Evaluator<'h>,
|
||||
/// The criteria argument of a database function, when it is a reference.
|
||||
criteria: Option<Area>,
|
||||
/// The LAMBDA given as the function argument of GROUPBY/PIVOTBY.
|
||||
lambda: Option<Arc<Lambda>>,
|
||||
}
|
||||
|
||||
impl Ctx for FnCtx<'_, '_> {
|
||||
@@ -124,6 +130,19 @@ impl Ctx for FnCtx<'_, '_> {
|
||||
let mut ev = Evaluator { host: &mut *self.ev.host, sheet: area.sheet, at, env, depth: self.ev.depth };
|
||||
Some(matches!(ev.value(&shifted).scalar().to_bool(), Ok(true)))
|
||||
}
|
||||
fn has_lambda(&self) -> bool {
|
||||
self.lambda.is_some()
|
||||
}
|
||||
/// A LAMBDA must give one value per group; an array result is #CALC!.
|
||||
fn call_lambda(&mut self, arg: Value) -> Option<Value> {
|
||||
let l = self.lambda.clone()?;
|
||||
let [p] = l.params.as_slice() else { return Some(Value::Error(CellError::Value)) };
|
||||
let r = self.ev.call_lambda_values(&l, vec![(p.clone(), Ev::V(arg))]);
|
||||
Some(match self.ev.deref(r) {
|
||||
Value::Array(a) if a.data.len() != 1 => Value::Error(CellError::Calc),
|
||||
v => v.scalar(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<'h> Evaluator<'h> {
|
||||
@@ -154,8 +173,13 @@ impl<'h> Evaluator<'h> {
|
||||
Ok(self.resolve_sheet(&r.sheet)?.into_iter().map(|sheet| Area { sheet, range }).collect())
|
||||
}
|
||||
|
||||
/// Dereferences: one cell → its value; a block → an array (trimmed to the used range);
|
||||
/// several areas → `#VALUE!`.
|
||||
/// Dereferences: one cell → its value; a block → an array; several areas → `#VALUE!`.
|
||||
///
|
||||
/// A block keeps its full shape (blanks included) when it holds at most `FULL_SIZE_CELLS`
|
||||
/// cells, or when it holds at most `FULL_SIZE_BOUNDED_CELLS` cells (one full column's worth)
|
||||
/// and does not run to the sheet's last row or column. Every other block (larger, or a
|
||||
/// bigger one that runs to the sheet's edge, such as `A:A` or `1:1`) is trimmed to the
|
||||
/// sheet's used range before its values are read. An array over `MAX_CELLS` is `#NUM!`.
|
||||
pub fn deref(&mut self, ev: Ev) -> Value {
|
||||
match ev {
|
||||
Ev::V(v) => v,
|
||||
@@ -174,7 +198,11 @@ impl<'h> Evaluator<'h> {
|
||||
// A reference keeps its size, so the array has its shape and its blanks count
|
||||
// (`=A1:A10`, `SUMPRODUCT(--(A1:A10=""))`).
|
||||
let cells = u64::from(a.range.height()) * u64::from(a.range.width());
|
||||
let range = if cells > FULL_SIZE_CELLS { self.trim(a) } else { a.range };
|
||||
// `A:A`, `1:1` and `A2:A1048576` run to the edge of the sheet: they stand for "the rest of
|
||||
// the column", not for a block of a chosen size.
|
||||
let to_edge = a.range.end.row == MAX_ROWS - 1 || a.range.end.col == MAX_COLS - 1;
|
||||
let full_size = cells <= FULL_SIZE_CELLS || (!to_edge && cells <= FULL_SIZE_BOUNDED_CELLS);
|
||||
let range = if full_size { a.range } else { self.trim(a) };
|
||||
let (h, w) = (range.height() as usize, range.width() as usize);
|
||||
if (h as u64) * (w as u64) > MAX_CELLS {
|
||||
return Value::Error(CellError::Num);
|
||||
@@ -256,13 +284,13 @@ impl<'h> Evaluator<'h> {
|
||||
}
|
||||
|
||||
fn name(&mut self, n: &str) -> Ev {
|
||||
if let Some((_, v)) = self.env.iter().rev().find(|(k, _)| k.eq_ignore_ascii_case(n)) {
|
||||
if let Some((_, v)) = self.env.iter().rev().find(|(k, _)| gridcraft_formula::same_name(k, n)) {
|
||||
return v.clone();
|
||||
}
|
||||
let wb = self.host.workbook();
|
||||
// Sheet-qualified `Sheet1!Name`.
|
||||
let (scope_sheet, bare) = match n.split_once('!') {
|
||||
Some((s, b)) => match wb.sheet_index(s.trim_matches('\'')) {
|
||||
Some((s, b)) => match wb.sheet_index(&gridcraft_core::unquote_sheet(s)) {
|
||||
Some(i) => (i, b),
|
||||
None => return err(CellError::Name),
|
||||
},
|
||||
@@ -271,7 +299,7 @@ impl<'h> Evaluator<'h> {
|
||||
if let Some(def) = wb.name(bare, scope_sheet) {
|
||||
let text = def.formula.clone();
|
||||
let scope = def.scope.unwrap_or(self.sheet);
|
||||
return match gridcraft_formula::parse(&text) {
|
||||
return match gridcraft_formula::parse_name(&text) {
|
||||
Ok(expr) => {
|
||||
// Relative references in a name are relative to A1: used here, they move by
|
||||
// this cell's offset from it.
|
||||
@@ -618,7 +646,7 @@ impl<'h> Evaluator<'h> {
|
||||
let value = self.value(a);
|
||||
evaluated.push(Arg { value, from_ref: false });
|
||||
}
|
||||
let mut ctx = FnCtx { ev: self, criteria: None };
|
||||
let mut ctx = FnCtx { ev: self, criteria: None, lambda: None };
|
||||
return gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V);
|
||||
}
|
||||
let i = match index.to_number() {
|
||||
@@ -772,7 +800,7 @@ impl<'h> Evaluator<'h> {
|
||||
Ev::L(Arc::new(Lambda { params, body: body.clone(), env: self.env.clone() }))
|
||||
}
|
||||
"ISOMITTED" => {
|
||||
let r = matches!(args.first(), Some(Expr::Name(n)) if self.env.iter().rev().find(|(k, _)| k.eq_ignore_ascii_case(n)).is_some_and(|(_, v)| matches!(v, Ev::V(Value::Empty))));
|
||||
let r = matches!(args.first(), Some(Expr::Name(n)) if self.env.iter().rev().find(|(k, _)| gridcraft_formula::same_name(k, n)).is_some_and(|(_, v)| matches!(v, Ev::V(Value::Empty))));
|
||||
Ev::V(Value::Bool(r))
|
||||
}
|
||||
"MAP" | "REDUCE" | "SCAN" | "BYROW" | "BYCOL" | "MAKEARRAY" => self.higher_order(name, args),
|
||||
@@ -817,8 +845,21 @@ impl<'h> Evaluator<'h> {
|
||||
let mut criteria_shape = None;
|
||||
// Database functions: the criteria range (argument 2), whose formulas are computed criteria.
|
||||
let mut criteria = None;
|
||||
// GROUPBY/PIVOTBY: the LAMBDA given as their function.
|
||||
let mut lambda = None;
|
||||
let lambda_at = match name {
|
||||
"GROUPBY" => Some(2),
|
||||
"PIVOTBY" => Some(3),
|
||||
_ => None,
|
||||
};
|
||||
for (i, a) in args.iter().enumerate() {
|
||||
let mut ev = self.eval(a);
|
||||
if lambda_at == Some(i)
|
||||
&& let Ev::L(l) = &ev
|
||||
{
|
||||
lambda = Some(l.clone());
|
||||
ev = Ev::V(Value::Empty);
|
||||
}
|
||||
if spec.category == gridcraft_functions::Category::Database
|
||||
&& i == 2
|
||||
&& let Ev::R(areas) = &ev
|
||||
@@ -853,7 +894,7 @@ impl<'h> Evaluator<'h> {
|
||||
};
|
||||
evaluated.push(Arg { value, from_ref });
|
||||
}
|
||||
let mut ctx = FnCtx { ev: self, criteria };
|
||||
let mut ctx = FnCtx { ev: self, criteria, lambda };
|
||||
gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V)
|
||||
}
|
||||
|
||||
@@ -907,13 +948,13 @@ impl<'h> Evaluator<'h> {
|
||||
};
|
||||
let args = [Arg { value, from_ref }];
|
||||
if kind != Agg::Count {
|
||||
match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self, criteria: None }) {
|
||||
match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self, criteria: None, lambda: None }) {
|
||||
Value::Number(n) => acc.sum += n,
|
||||
other => return Err(Ev::V(other)),
|
||||
}
|
||||
}
|
||||
if kind != Agg::Sum {
|
||||
match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self, criteria: None }) {
|
||||
match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self, criteria: None, lambda: None }) {
|
||||
Value::Number(n) => acc.count = acc.count.saturating_add(n as u64),
|
||||
other => return Err(Ev::V(other)),
|
||||
}
|
||||
@@ -975,7 +1016,7 @@ impl<'h> Evaluator<'h> {
|
||||
/// that name.
|
||||
fn function_by_name(&self, e: &Expr) -> Option<String> {
|
||||
let Expr::Name(n) = e else { return None };
|
||||
if self.env.iter().any(|(k, _)| k.eq_ignore_ascii_case(n)) || self.host.workbook().name(n, self.sheet).is_some() {
|
||||
if self.env.iter().any(|(k, _)| gridcraft_formula::same_name(k, n)) || self.host.workbook().name(n, self.sheet).is_some() {
|
||||
return None;
|
||||
}
|
||||
let u = n.to_ascii_uppercase();
|
||||
@@ -1012,15 +1053,18 @@ impl<'h> Evaluator<'h> {
|
||||
let arrays: Vec<Arc<Array>> = args[..args.len() - 1].iter().map(|a| as_array(self.value(a))).collect();
|
||||
let rows = arrays.iter().map(|a| a.rows).max().unwrap_or(1);
|
||||
let cols = arrays.iter().map(|a| a.cols).max().unwrap_or(1);
|
||||
if (rows * cols) as u64 > MAX_CELLS {
|
||||
return err(CellError::Num);
|
||||
let Some(n) = broadcast_len(rows, cols) else { return err(CellError::Num) };
|
||||
// The LAMBDA takes one parameter per array.
|
||||
if l.params.len() != arrays.len() {
|
||||
return err(CellError::Value);
|
||||
}
|
||||
let mut out = Vec::with_capacity(rows * cols);
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for r in 0..rows {
|
||||
for c in 0..cols {
|
||||
let bound = l.params.iter().zip(arrays.iter()).map(|(p, a)| (p.clone(), Ev::V(a.get_broadcast(r, c)))).collect();
|
||||
let v = self.call_lambda_values(&l, bound);
|
||||
out.push(self.deref(v).scalar());
|
||||
let Some(one) = single(self.deref(v)) else { return err(CellError::Calc) };
|
||||
out.push(one);
|
||||
}
|
||||
}
|
||||
Ev::V(Array::new(rows, cols, out).map(Value::from).unwrap_or(Value::Error(CellError::Calc)))
|
||||
@@ -1041,7 +1085,8 @@ impl<'h> Evaluator<'h> {
|
||||
let r = self.call_lambda_values(&l, bound);
|
||||
acc = self.deref(r);
|
||||
if name == "SCAN" {
|
||||
scan.push(acc.scalar());
|
||||
let Some(one) = single(acc.clone()) else { return err(CellError::Calc) };
|
||||
scan.push(one);
|
||||
}
|
||||
}
|
||||
if name == "SCAN" {
|
||||
@@ -1057,6 +1102,9 @@ impl<'h> Evaluator<'h> {
|
||||
let arr = as_array(self.value(&args[0]));
|
||||
let Some(l) = self.lambda_arg(args.get(1), 1) else { return err(CellError::Value) };
|
||||
let Some(p) = l.params.first().cloned() else { return err(CellError::Value) };
|
||||
if l.params.len() != 1 {
|
||||
return err(CellError::Value);
|
||||
}
|
||||
let by_row = name == "BYROW";
|
||||
let n = if by_row { arr.rows } else { arr.cols };
|
||||
let mut out = Vec::with_capacity(n);
|
||||
@@ -1068,7 +1116,8 @@ impl<'h> Evaluator<'h> {
|
||||
};
|
||||
let a = if by_row { Array::row(slice) } else { Array::column(slice) };
|
||||
let r = self.call_lambda_values(&l, vec![(p.clone(), Ev::V(a.into()))]);
|
||||
out.push(self.deref(r).scalar());
|
||||
let Some(one) = single(self.deref(r)) else { return err(CellError::Calc) };
|
||||
out.push(one);
|
||||
}
|
||||
let a = if by_row { Array::column(out) } else { Array::row(out) };
|
||||
Ev::V(a.into())
|
||||
@@ -1097,7 +1146,8 @@ impl<'h> Evaluator<'h> {
|
||||
(l.params[1].clone(), Ev::V(Value::number(c as f64 + 1.0))),
|
||||
];
|
||||
let v = self.call_lambda_values(&l, bound);
|
||||
out.push(self.deref(v).scalar());
|
||||
let Some(one) = single(self.deref(v)) else { return err(CellError::Calc) };
|
||||
out.push(one);
|
||||
}
|
||||
}
|
||||
Ev::V(Array::new(rows, cols, out).map(Value::from).unwrap_or(Value::Error(CellError::Calc)))
|
||||
@@ -1318,7 +1368,7 @@ impl<'h> Evaluator<'h> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ctx = FnCtx { ev: self, criteria: None };
|
||||
let mut ctx = FnCtx { ev: self, criteria: None, lambda: None };
|
||||
return match gridcraft_functions::call(spec, &xargs, &mut ctx) {
|
||||
Value::Number(p) if p >= 1.0 => {
|
||||
let k = p as u32 - 1;
|
||||
@@ -1373,7 +1423,16 @@ impl<'h> Evaluator<'h> {
|
||||
}))
|
||||
}
|
||||
"width" => Ev::V(Value::number(sheet.map_or(8.43, |s| gridcraft_model::points_to_chars(s.col_width(c.col) as f64)).round())),
|
||||
"filename" => Ev::V(Value::text(String::new())),
|
||||
// Excel's `<folder>/[<file>]<sheet>`, which the classic sheet-name formula splits on
|
||||
// the `]`. Empty until the workbook has a file of its own, as in Excel (#1489).
|
||||
"filename" => Ev::V(Value::text(match &wb.source_path {
|
||||
Some(file) => {
|
||||
let name = file.file_name().unwrap_or_default().to_string_lossy();
|
||||
let on = sheet.map_or("", |s| s.name.as_str());
|
||||
file.parent().unwrap_or(std::path::Path::new("")).join(format!("[{name}]{on}")).to_string_lossy().into_owned()
|
||||
}
|
||||
None => String::new(),
|
||||
})),
|
||||
"sheetname" => Ev::V(Value::text(sheet.map(|s| s.name.clone()).unwrap_or_default())),
|
||||
"protect" => Ev::V(Value::number(if sheet.is_some_and(|s| wb.styles.get(s.style_id(c)).protection.locked) { 1.0 } else { 0.0 })),
|
||||
"format" => {
|
||||
@@ -1422,43 +1481,74 @@ impl<'h> Evaluator<'h> {
|
||||
if name == "AGGREGATE" && func >= 14 {
|
||||
k = rest.get(1).map(|e| self.value(e));
|
||||
}
|
||||
// Every cell visited (even one skipped as hidden, nested or an error) and every value
|
||||
// counts against one budget for the whole call. All arguments are evaluated (references
|
||||
// stay lazy) and charged before any cell is read: a value stored in XFD1048576 makes
|
||||
// `A:XFD` trim to the whole sheet, ~17 billion cells (#2175).
|
||||
enum Input {
|
||||
Cells(usize, RangeRef),
|
||||
Values(Value),
|
||||
}
|
||||
let mut inputs = Vec::new();
|
||||
let mut budget = MAX_CELLS;
|
||||
for e in refs {
|
||||
match self.eval(e) {
|
||||
Ev::R(areas) => {
|
||||
for a in areas {
|
||||
let range = self.trim(&a);
|
||||
for c in range.iter() {
|
||||
let (hidden, nested) = {
|
||||
let wb = self.host.workbook();
|
||||
let s = wb.sheet(a.sheet);
|
||||
let hidden =
|
||||
s.is_some_and(|s| if skip_hidden { s.is_row_hidden(c.row) } else { skip_filtered && s.is_row_filtered(c.row) });
|
||||
let nested = skip_nested
|
||||
&& s.and_then(|s| s.cell(c)).and_then(|cell| cell.formula.as_ref()).is_some_and(|f| {
|
||||
let u = f.text.to_ascii_uppercase();
|
||||
u.contains("SUBTOTAL(") || u.contains("AGGREGATE(")
|
||||
});
|
||||
(hidden, nested)
|
||||
};
|
||||
if hidden || nested {
|
||||
continue;
|
||||
}
|
||||
let v = self.host.cell_value(a.sheet, c);
|
||||
if skip_errors && v.is_error() {
|
||||
continue;
|
||||
}
|
||||
values.push(v);
|
||||
}
|
||||
budget = match budget.checked_sub(range.count()) {
|
||||
Some(left) => left,
|
||||
None => return err(CellError::Num),
|
||||
};
|
||||
inputs.push(Input::Cells(a.sheet, range));
|
||||
}
|
||||
}
|
||||
Ev::V(Value::Array(a)) => values.extend(a.data.iter().filter(|v| !(skip_errors && v.is_error())).cloned()),
|
||||
Ev::V(v) => values.push(v),
|
||||
Ev::V(v) => {
|
||||
let n = match &v {
|
||||
Value::Array(a) => u64::try_from(a.data.len()).unwrap_or(u64::MAX),
|
||||
_ => 1,
|
||||
};
|
||||
budget = match budget.checked_sub(n) {
|
||||
Some(left) => left,
|
||||
None => return err(CellError::Num),
|
||||
};
|
||||
inputs.push(Input::Values(v));
|
||||
}
|
||||
Ev::L(_) => return err(CellError::Value),
|
||||
}
|
||||
}
|
||||
for input in inputs {
|
||||
match input {
|
||||
Input::Cells(sheet, range) => {
|
||||
for c in range.iter() {
|
||||
let (hidden, nested) = {
|
||||
let wb = self.host.workbook();
|
||||
let s = wb.sheet(sheet);
|
||||
let hidden =
|
||||
s.is_some_and(|s| if skip_hidden { s.is_row_hidden(c.row) } else { skip_filtered && s.is_row_filtered(c.row) });
|
||||
let nested = skip_nested
|
||||
&& s.and_then(|s| s.cell(c)).and_then(|cell| cell.formula.as_ref()).is_some_and(|f| {
|
||||
let u = f.text.to_ascii_uppercase();
|
||||
u.contains("SUBTOTAL(") || u.contains("AGGREGATE(")
|
||||
});
|
||||
(hidden, nested)
|
||||
};
|
||||
if hidden || nested {
|
||||
continue;
|
||||
}
|
||||
let v = self.host.cell_value(sheet, c);
|
||||
if skip_errors && v.is_error() {
|
||||
continue;
|
||||
}
|
||||
values.push(v);
|
||||
}
|
||||
}
|
||||
Input::Values(Value::Array(a)) => values.extend(a.data.iter().filter(|v| !(skip_errors && v.is_error())).cloned()),
|
||||
Input::Values(v) => values.push(v),
|
||||
}
|
||||
}
|
||||
if let Some(k) = k {
|
||||
values.push(Value::Empty);
|
||||
return Ev::V(gridcraft_functions::aggregate_values_k(func, &values[..values.len() - 1], &k));
|
||||
return Ev::V(gridcraft_functions::aggregate_values_k(func, &values, &k));
|
||||
}
|
||||
Ev::V(gridcraft_functions::aggregate_values(func, &values))
|
||||
}
|
||||
@@ -1615,7 +1705,8 @@ pub fn zip2(a: &Value, b: &Value, f: impl Fn(&Value, &Value) -> Value) -> Value
|
||||
(Value::Array(x), Value::Array(y)) => {
|
||||
let rows = broadcast_dim(x.rows, y.rows);
|
||||
let cols = broadcast_dim(x.cols, y.cols);
|
||||
let mut data = Vec::with_capacity(rows * cols);
|
||||
let Some(n) = broadcast_len(rows, cols) else { return Value::Error(CellError::Num) };
|
||||
let mut data = Vec::with_capacity(n);
|
||||
for r in 0..rows {
|
||||
for c in 0..cols {
|
||||
data.push(f(&x.get_broadcast(r, c), &y.get_broadcast(r, c)));
|
||||
@@ -1629,6 +1720,13 @@ 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.
|
||||
fn broadcast_len(rows: usize, cols: usize) -> Option<usize> {
|
||||
rows.checked_mul(cols).filter(|&n| n as u64 <= MAX_CELLS)
|
||||
}
|
||||
|
||||
fn broadcast_dim(a: usize, b: usize) -> usize {
|
||||
if a == 1 {
|
||||
b
|
||||
@@ -1645,8 +1743,9 @@ fn zip3(c: &Array, t: &Value, f: &Value, g: impl Fn(&Value, &Value, &Value) -> V
|
||||
let (fr, fc) = dims(f);
|
||||
let rows = broadcast_dim(broadcast_dim(c.rows, tr), fr);
|
||||
let cols = broadcast_dim(broadcast_dim(c.cols, tc), fc);
|
||||
let Some(n) = broadcast_len(rows, cols) else { return Value::Error(CellError::Num) };
|
||||
let get = |v: &Value, r: usize, col: usize| if let Value::Array(a) = v { a.get_broadcast(r, col) } else { v.clone() };
|
||||
let mut data = Vec::with_capacity(rows * cols);
|
||||
let mut data = Vec::with_capacity(n);
|
||||
for r in 0..rows {
|
||||
for col in 0..cols {
|
||||
data.push(g(&c.get_broadcast(r, col), &get(t, r, col), &get(f, r, col)));
|
||||
@@ -1655,6 +1754,15 @@ fn zip3(c: &Array, t: &Value, f: &Value, g: impl Fn(&Value, &Value, &Value) -> V
|
||||
Value::from(Array { rows, cols, data })
|
||||
}
|
||||
|
||||
/// One cell of a LAMBDA helper's result, or `None` for an array of more than one value: arrays
|
||||
/// can't nest, so the whole helper gives `#CALC!`, as in Excel.
|
||||
fn single(v: Value) -> Option<Value> {
|
||||
match v {
|
||||
Value::Array(a) if a.rows * a.cols > 1 => None,
|
||||
v => Some(v.scalar()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn binary_values(op: BinOp, x: &Value, y: &Value, sys: gridcraft_core::DateSystem) -> Value {
|
||||
zip2(x, y, |a, b| binary_scalar(op, a, b, sys))
|
||||
}
|
||||
|
||||
+179
-9
@@ -60,6 +60,12 @@ const WIDE: u32 = 64;
|
||||
/// Insertions an [`Intervals`] keeps aside (scanned by every query) before merging them in.
|
||||
const PENDING: usize = 32;
|
||||
|
||||
/// Formulas a formula may calculate on demand one inside the other (each needing one another's
|
||||
/// value before it is done). Each level takes stack, so a chain deeper than this is suspended and
|
||||
/// calculated from the top instead (see [`PassHost::calculate`]): long dependency chains calculate
|
||||
/// whatever order they come in, with bounded stack.
|
||||
const MAX_NESTED: usize = 64;
|
||||
|
||||
/// Formula evaluations iterative calculation may spend on one recalculation (passes × formulas
|
||||
/// evaluated again), however high Maximum Iterations is.
|
||||
const MAX_ITERATION_WORK: usize = 10_000_000;
|
||||
@@ -395,6 +401,24 @@ struct PassHost<'a> {
|
||||
cycle: bool,
|
||||
cycles: Vec<Key>,
|
||||
depth: usize,
|
||||
/// A formula needed at [`MAX_NESTED`] levels deep: the evaluations in progress are abandoned
|
||||
/// and it is calculated first.
|
||||
suspend: Option<Key>,
|
||||
/// Formulas whose evaluation was abandoned for another one, to be calculated again after it.
|
||||
/// They all need it, so reading one of them while it is calculated is a circular reference,
|
||||
/// as reading a formula in progress is.
|
||||
waiting: HashSet<Key>,
|
||||
/// The formulas abandoned since the last suspension.
|
||||
abandoned: Vec<Key>,
|
||||
/// Other formulas read at [`MAX_NESTED`] levels deep by the evaluations being abandoned. They
|
||||
/// are calculated along with the suspended one, rather than one per restart (a wide range
|
||||
/// read at the limit would restart the abandoned evaluations once per cell in it).
|
||||
also: Vec<Key>,
|
||||
/// Suspensions so far (tests check that a wide read at the limit needs one, not one per cell).
|
||||
#[cfg(test)]
|
||||
suspensions: usize,
|
||||
/// The formulas in `cycles`.
|
||||
cycle_seen: HashSet<Key>,
|
||||
/// Iterative calculation: a circular reference reads the cell's previous value instead of
|
||||
/// giving `#CIRC!`.
|
||||
iterate: bool,
|
||||
@@ -425,6 +449,13 @@ impl<'a> PassHost<'a> {
|
||||
cycle: false,
|
||||
cycles: vec![],
|
||||
depth: 0,
|
||||
suspend: None,
|
||||
waiting: HashSet::default(),
|
||||
abandoned: Vec::new(),
|
||||
also: Vec::new(),
|
||||
#[cfg(test)]
|
||||
suspensions: 0,
|
||||
cycle_seen: HashSet::default(),
|
||||
iterate: false,
|
||||
prev: HashMap::default(),
|
||||
stack: Vec::new(),
|
||||
@@ -456,7 +487,65 @@ fn spill_blocked(host: &PassHost<'_>, (sheet, anchor): Key, a: &Array) -> bool {
|
||||
|| sh.spill.range(range.start..=range.end).any(|(c, _)| range.contains(*c) && spilled_over(*c))
|
||||
}
|
||||
|
||||
/// Whether `arr`, anchored at `anchor`, would spill exactly what the sheet already holds over
|
||||
/// `range`: the same area, nothing new in the way, and the same values.
|
||||
fn spills_as_before(sheet: &gridcraft_model::Sheet, anchor: CellRef, range: RangeRef, arr: &Array) -> bool {
|
||||
let end =
|
||||
CellRef::new(anchor.row.saturating_add((arr.rows as u32).saturating_sub(1)), anchor.col.saturating_add((arr.cols as u32).saturating_sub(1)));
|
||||
RangeRef::new(anchor, end) == range
|
||||
&& !sheet.merges.iter().any(|m| m.intersects(&range))
|
||||
&& !sheet.tables.iter().any(|t| t.range.contains(anchor))
|
||||
&& range
|
||||
.iter()
|
||||
.enumerate()
|
||||
.all(|(i, c)| c == anchor || (!sheet.cells.has(c) && sheet.spill.get(&c) == Some(arr.data.get(i).unwrap_or(&Value::Empty))))
|
||||
}
|
||||
|
||||
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
|
||||
/// calculated from here, then the suspended ones again (reading the results kept so far).
|
||||
fn calculate(&mut self, k: Key) {
|
||||
// Formulas to calculate, innermost last, each with the ones abandoned to calculate the
|
||||
// next.
|
||||
let mut work: Vec<(Key, Vec<Key>)> = vec![(k, Vec::new())];
|
||||
while let Some(&(top, _)) = work.last() {
|
||||
self.compute(top);
|
||||
match self.suspend.take() {
|
||||
Some(next) => {
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.suspensions += 1;
|
||||
}
|
||||
let held = std::mem::take(&mut self.abandoned);
|
||||
self.waiting.extend(held.iter().copied());
|
||||
if let Some(entry) = work.last_mut() {
|
||||
entry.1 = held;
|
||||
}
|
||||
// The other formulas read at the limit are calculated after `next`, before the
|
||||
// held evaluations restart. Each has nothing held of its own, so the held ones
|
||||
// are released only when the last of them is done.
|
||||
let mut seen: HashSet<Key> = HashSet::default();
|
||||
seen.insert(next);
|
||||
for s in std::mem::take(&mut self.also) {
|
||||
if seen.insert(s) {
|
||||
work.push((s, Vec::new()));
|
||||
}
|
||||
}
|
||||
work.push((next, Vec::new()));
|
||||
}
|
||||
None => {
|
||||
work.pop();
|
||||
if let Some(entry) = work.last_mut() {
|
||||
for a in entry.1.drain(..) {
|
||||
self.waiting.remove(&a);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn compute(&mut self, k: Key) -> Value {
|
||||
if let Some(v) = self.results.get(&k) {
|
||||
let v = v.clone();
|
||||
@@ -468,14 +557,29 @@ impl PassHost<'_> {
|
||||
let Some(expr) = self.exprs.get(&k) else {
|
||||
return self.wb.sheet(k.0).map(|s| s.value(k.1)).unwrap_or_default();
|
||||
};
|
||||
if self.in_progress.contains(&k) || self.depth > 2000 {
|
||||
if self.suspend.is_some() {
|
||||
// Unwinding to `calculate`: the value is thrown away. A formula read at the limit is
|
||||
// calculated with the suspended one, so the evaluations are not restarted for each.
|
||||
if self.depth >= MAX_NESTED && !self.in_progress.contains(&k) && !self.waiting.contains(&k) {
|
||||
self.also.push(k);
|
||||
}
|
||||
return Value::Error(CellError::Calc);
|
||||
}
|
||||
if self.in_progress.contains(&k) || self.waiting.contains(&k) {
|
||||
self.cycle = true;
|
||||
if self.iterate {
|
||||
return self.circular_read(k);
|
||||
}
|
||||
self.cycles.push(k);
|
||||
// Once each, though a suspended evaluation meets it again.
|
||||
if self.cycle_seen.insert(k) {
|
||||
self.cycles.push(k);
|
||||
}
|
||||
return Value::Error(CellError::Circ);
|
||||
}
|
||||
if self.depth >= MAX_NESTED {
|
||||
self.suspend = Some(k);
|
||||
return Value::Error(CellError::Calc);
|
||||
}
|
||||
self.in_progress.insert(k);
|
||||
self.depth += 1;
|
||||
let outer_taint = std::mem::replace(&mut self.taint, false);
|
||||
@@ -487,6 +591,17 @@ impl PassHost<'_> {
|
||||
let mut ev = Evaluator::new(self, k.0, k.1);
|
||||
ev.value(&expr)
|
||||
};
|
||||
if self.suspend.is_some() {
|
||||
// Abandoned: `calculate` evaluates this formula again once the one it needs is done.
|
||||
self.depth -= 1;
|
||||
self.in_progress.remove(&k);
|
||||
self.abandoned.push(k);
|
||||
if self.iterate {
|
||||
self.stack.pop();
|
||||
}
|
||||
self.taint = outer_taint;
|
||||
return Value::Error(CellError::Calc);
|
||||
}
|
||||
// A legacy array formula fills the range it was entered in.
|
||||
let legacy = self.wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.as_ref()).and_then(|f| f.array);
|
||||
let v = match legacy {
|
||||
@@ -517,9 +632,11 @@ impl PassHost<'_> {
|
||||
let data = a.data.iter().map(|x| if matches!(x, Value::Empty) { Value::Number(0.0) } else { x.clone() }).collect();
|
||||
Array::new(a.rows, a.cols, data).map(std::sync::Arc::new).unwrap_or(a)
|
||||
};
|
||||
if a.rows > 1 || a.cols > 1 {
|
||||
self.spills.insert(k, a.clone());
|
||||
}
|
||||
// A dynamic-array formula anchors a spill range even while its result is one
|
||||
// value -- a FILTER matching one row, a SORT or UNIQUE over one item -- so `C1#`
|
||||
// is the 1 x 1 range and `ROWS(C1#)` is 1, as in Excel (#1491). A formula that
|
||||
// returns a scalar never reaches this arm, so it does not become an anchor.
|
||||
self.spills.insert(k, a.clone());
|
||||
a.data.first().cloned().unwrap_or_default()
|
||||
}
|
||||
Value::Empty => Value::Number(0.0),
|
||||
@@ -577,7 +694,7 @@ impl PassHost<'_> {
|
||||
self.pending = set.clone();
|
||||
self.spills.retain(|a, _| !set.contains(a));
|
||||
for k in &again {
|
||||
self.compute(*k);
|
||||
self.calculate(*k);
|
||||
}
|
||||
passes += 1;
|
||||
}
|
||||
@@ -950,6 +1067,7 @@ impl Calc {
|
||||
/// Evaluates the dirty set and writes results (and spills) back.
|
||||
fn run(&mut self, wb: &mut Workbook, mut dirty: Vec<Key>, full: bool) {
|
||||
let mut dynamic_again = !self.graph.dynamic.is_empty();
|
||||
self.last_recalc_cells = 0;
|
||||
for _round in 0..8 {
|
||||
if dirty.is_empty() {
|
||||
break;
|
||||
@@ -969,7 +1087,7 @@ impl Calc {
|
||||
let mut host = PassHost::new(wb, &exprs, &mut rng, now);
|
||||
host.iterate = wb.calc.iterative;
|
||||
for k in &dirty {
|
||||
host.compute(*k);
|
||||
host.calculate(*k);
|
||||
}
|
||||
if host.iterate && !host.members.is_empty() {
|
||||
let passes = host.iterate_loops(&dirty, wb.calc.max_iterations, wb.calc.max_change);
|
||||
@@ -984,7 +1102,7 @@ impl Calc {
|
||||
eprintln!(" eval {} formulas {:.1} ms", results.len(), prof_now() - tp);
|
||||
}
|
||||
let tw = prof_now();
|
||||
self.last_recalc_cells = results.len();
|
||||
self.last_recalc_cells += results.len();
|
||||
self.circular = cycles;
|
||||
// Write back.
|
||||
let mut spill_changes: Vec<Key> = Vec::new();
|
||||
@@ -1004,6 +1122,14 @@ impl Calc {
|
||||
if old.is_none() && new.is_none() {
|
||||
continue;
|
||||
}
|
||||
// An array that spills exactly as before changes nothing its dependents read, so
|
||||
// they aren't queued again. Otherwise two arrays that read each other's spill areas
|
||||
// queue each other in every round, up to the round limit.
|
||||
if let (Some(r), Some(arr)) = (old, new)
|
||||
&& spills_as_before(sheet, k.1, r, arr)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(r) = old {
|
||||
for c in r.iter() {
|
||||
sheet.spill.remove(&c);
|
||||
@@ -1023,6 +1149,13 @@ impl Calc {
|
||||
if let Some(cell) = sheet.cells.get_mut(k.1) {
|
||||
cell.value = Value::Error(CellError::Spill);
|
||||
}
|
||||
// Not blocked while the pass ran (it would have been #SPILL! there), so
|
||||
// blocked by another array of this pass: the formulas that read this one,
|
||||
// at its anchor or over the area it would have spilled into, read #SPILL!
|
||||
// or empty cells now.
|
||||
let mut deps = Vec::new();
|
||||
self.graph.dependents_of_range(k.0, range, &mut deps);
|
||||
spill_changes.extend(deps.into_iter().filter(|d| d != k));
|
||||
} else {
|
||||
for (i, c) in range.iter().enumerate() {
|
||||
if c != k.1 {
|
||||
@@ -1036,7 +1169,15 @@ impl Calc {
|
||||
if let Some(r) = changed_range {
|
||||
let mut deps = Vec::new();
|
||||
self.graph.dependents_of_range(k.0, r, &mut deps);
|
||||
spill_changes.extend(deps.into_iter().filter(|d| d != k));
|
||||
// Not the anchor itself (that would read its own array), unless its old array
|
||||
// is gone or partly freed: its new formula may have read the old values, still
|
||||
// laid out while it was evaluated.
|
||||
let freed = match (old, sheet.spill_ranges.get(&k.1)) {
|
||||
(Some(o), Some(n)) => !(n.contains(o.start) && n.contains(o.end)),
|
||||
(_, None) => true,
|
||||
(None, Some(_)) => false,
|
||||
};
|
||||
spill_changes.extend(deps.into_iter().filter(|d| d != k || freed));
|
||||
}
|
||||
if let Some(o) = old
|
||||
&& !sheet.spill_ranges.get(&k.1).is_some_and(|n| n.contains(o.start) && n.contains(o.end))
|
||||
@@ -1322,6 +1463,35 @@ pub(crate) mod tests {
|
||||
assert_eq!(deps, vec![at("DA9"), at("DA9")]);
|
||||
}
|
||||
|
||||
/// A formula at the nesting limit reading thousands of formulas not calculated yet suspends the
|
||||
/// evaluations in progress once, not once per formula read.
|
||||
#[test]
|
||||
fn wide_read_at_the_nesting_limit_suspends_once() {
|
||||
const WIDTH: u32 = 3000;
|
||||
let mut wb = Workbook::new();
|
||||
let mut exprs = HashMap::default();
|
||||
let mut put = |wb: &mut Workbook, at: String, text: String| {
|
||||
let at = CellRef::parse(&at).unwrap();
|
||||
wb.sheet_mut(0).unwrap().cells.set(at, Cell::formula(Formula::new(&text)));
|
||||
exprs.insert((0, at), std::sync::Arc::new(gridcraft_formula::parse(&text[1..]).unwrap()));
|
||||
};
|
||||
// A1 reads A2, ..., A64 (the 64th formula deep) reads a wide range of formulas.
|
||||
for r in 1..64 {
|
||||
put(&mut wb, format!("A{r}"), format!("=A{}+1", r + 1));
|
||||
}
|
||||
put(&mut wb, "A64".to_string(), format!("=SUM(B1:B{WIDTH})"));
|
||||
for r in 1..=WIDTH {
|
||||
put(&mut wb, format!("B{r}"), format!("={r}*2"));
|
||||
}
|
||||
let mut rng = 1u64;
|
||||
let mut host = PassHost::new(&wb, &exprs, &mut rng, 0.0);
|
||||
host.calculate((0, CellRef::parse("A1").unwrap()));
|
||||
let total = (WIDTH * (WIDTH + 1)) as f64;
|
||||
assert_eq!(host.results.get(&(0, CellRef::parse("A1").unwrap())), Some(&Value::Number(total + 63.0)));
|
||||
assert!(host.cycles.is_empty());
|
||||
assert!(host.suspensions <= 3, "{} suspensions", host.suspensions);
|
||||
}
|
||||
|
||||
/// For tests of `PassHost::visit_range`, which can't be reached from outside this module: the
|
||||
/// values it hands over for `range` while the formulas in `pending` (cell, formula) have not been
|
||||
/// calculated yet, or `None` when it declines the block.
|
||||
|
||||
@@ -67,6 +67,25 @@ fn spill_range_operator() {
|
||||
assert_eq!(t.num("E1"), 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrays_that_spill_as_before_do_not_requeue_their_dependents() {
|
||||
// Two arrays that each read the other's spill area, and a formula reading both spills.
|
||||
let mut t = T::new();
|
||||
t.set("A1", "=SEQUENCE(2)+0*B2");
|
||||
t.set("B1", "=SEQUENCE(2)+0*A2");
|
||||
t.set("C1", "=SUM(A1#)+SUM(B1#)");
|
||||
// An edit that spills the same values again: one round, not one per round up to the limit.
|
||||
t.set("A1", "=SEQUENCE(2)+0*B2+0");
|
||||
assert_eq!((t.num("A2"), t.num("B2"), t.num("C1")), (2.0, 2.0, 6.0));
|
||||
assert!(t.calc.last_recalc_cells <= 3, "{} formulas evaluated for one edit", t.calc.last_recalc_cells);
|
||||
// Only the anchor's value changes: formulas reading the whole spill still see it.
|
||||
t.set("A1", "={7;2}+0*B2");
|
||||
assert_eq!((t.num("A1"), t.num("C1")), (7.0, 12.0));
|
||||
// A spill that does change still reaches its dependents.
|
||||
t.set("A1", "=SEQUENCE(2)*10+0*B2");
|
||||
assert_eq!((t.num("A2"), t.num("C1")), (20.0, 33.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arithmetic_and_dependencies() {
|
||||
let mut t = T::new();
|
||||
@@ -310,6 +329,29 @@ fn date_text_coerces_in_the_workbook_date_system() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lambda_helpers_check_shapes_and_parameters() {
|
||||
let mut t = T::new();
|
||||
let calc = Value::Error(CellError::Calc);
|
||||
// A LAMBDA returning several values can't fill one cell: the whole result is #CALC!.
|
||||
t.set("A1", "=MAP({1,2},LAMBDA(x,{1,2}))");
|
||||
assert_eq!((t.get("A1"), t.get("B1")), (calc.clone(), Value::Empty));
|
||||
t.set("A2", "=BYROW({1,2;3,4},LAMBDA(r,r))");
|
||||
assert_eq!((t.get("A2"), t.get("A3")), (calc.clone(), Value::Empty));
|
||||
t.set("A4", "=MAKEARRAY(1,2,LAMBDA(r,c,{1,2}))");
|
||||
assert_eq!(t.get("A4"), calc.clone());
|
||||
t.set("D1", "=SCAN(0,{1,2},LAMBDA(a,b,{1,2}))");
|
||||
assert_eq!((t.get("D1"), t.get("E1")), (calc, Value::Empty));
|
||||
// One parameter per array (MAP) or exactly one (BYROW/BYCOL).
|
||||
t.set("A5", "=MAP({1,2},LAMBDA(x,y,x))");
|
||||
assert_eq!(t.get("A5"), Value::Error(CellError::Value));
|
||||
t.set("A6", "=BYCOL({1,2},LAMBDA(c,d,SUM(c)))");
|
||||
assert_eq!(t.get("A6"), Value::Error(CellError::Value));
|
||||
// A single value in an array is fine.
|
||||
t.set("A7", "=MAP({1,2},LAMBDA(x,SEQUENCE(1)*x))");
|
||||
assert_eq!(t.num("B7"), 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operators() {
|
||||
let mut t = T::new();
|
||||
@@ -368,6 +410,25 @@ fn spill_into_the_old_area_of_a_blocked_neighbour() {
|
||||
assert_eq!([t.num("C8"), t.num("D8"), t.num("C9"), t.num("D9")], [1.0, 2.0, 3.0, 4.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formulas_reading_an_array_that_loses_a_spill_conflict_read_spill_errors() {
|
||||
// Two arrays of one recalculation want the same empty cell: the one laid out second is
|
||||
// #SPILL!, and the formulas that read it in that pass (at its anchor, through `A2#`, or over
|
||||
// the cells it would have spilled into) kept its values.
|
||||
let mut t = T::new();
|
||||
t.set("B1", "=SEQUENCE(3)");
|
||||
t.set("A2", "=SEQUENCE(1,3)");
|
||||
t.set("D2", "=A2*10");
|
||||
t.set("E2", "=C2");
|
||||
t.set("F2", "=SUM(A2#)");
|
||||
t.set("G2", "=SUM(C2:C3)");
|
||||
// Both arrays recalculated together: B1 is laid out first and takes B2.
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
let spill = Value::Error(CellError::Spill);
|
||||
assert_eq!((t.num("B2"), t.get("A2"), t.get("D2")), (2.0, spill.clone(), spill));
|
||||
assert_eq!((t.get("C2"), t.num("E2"), t.get("F2"), t.num("G2")), (Value::Empty, 0.0, Value::Error(CellError::Ref), 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrays_do_not_spill_in_tables() {
|
||||
// Excel: spilled array formulas aren't supported in tables.
|
||||
@@ -543,6 +604,25 @@ fn spills() {
|
||||
assert_eq!(t.num("D2"), 40.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_anchor_given_a_formula_that_reads_its_old_spill_area_sees_it_empty() {
|
||||
// The new formula was evaluated while the old array was still laid out, and only the
|
||||
// anchor's dependents were evaluated again once it was gone.
|
||||
let mut t = T::new();
|
||||
t.set("A1", "=SEQUENCE(3)");
|
||||
assert_eq!(t.num("A3"), 3.0);
|
||||
t.set("A1", "=SUM(A2:A3)+1");
|
||||
assert_eq!((t.num("A1"), t.get("A3")), (1.0, Value::Empty));
|
||||
// Still an array, now spilling elsewhere: it read A2 as 2 and spilled three columns.
|
||||
t.set("A1", "=SEQUENCE(3)");
|
||||
t.set("A1", "=SEQUENCE(1,A2+1)");
|
||||
assert_eq!((t.num("A1"), t.get("B1"), t.get("A2")), (1.0, Value::Empty, Value::Empty));
|
||||
// A smaller array inside the old area: it read A3 as 3 and spilled over A1:A2.
|
||||
t.set("A1", "=SEQUENCE(3)");
|
||||
t.set("A1", "=SEQUENCE(MAX(1,A3-1))");
|
||||
assert_eq!((t.num("A1"), t.get("A2"), t.get("A3")), (1.0, Value::Empty, Value::Empty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn references_keep_their_size_past_the_used_range() {
|
||||
let mut t = T::new();
|
||||
@@ -590,6 +670,27 @@ fn let_lambda() {
|
||||
assert_eq!(t.num("A4"), 14.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn let_lambda_names_ignore_case_in_any_script() {
|
||||
let mut t = T::new();
|
||||
t.set("A1", "=LET(dług,100,DŁUG*2)");
|
||||
assert_eq!(t.num("A1"), 200.0);
|
||||
t.set("A2", "=LAMBDA(λ,ISOMITTED(Λ))()");
|
||||
assert_eq!(t.get("A2"), Value::Bool(true));
|
||||
t.set("A3", "=LAMBDA(ćma,ĆMA+1)(1)");
|
||||
assert_eq!(t.num("A3"), 2.0);
|
||||
t.set("A4", "=LET(ŻAR,LAMBDA(x,x*3),żar(2))");
|
||||
assert_eq!(t.num("A4"), 6.0);
|
||||
t.set("A5", "=LET(λ,LAMBDA(x,x+1),SUM(MAP({1,2},Λ)))");
|
||||
assert_eq!(t.num("A5"), 5.0);
|
||||
// `ß` upper-cases to `SS`, yet `ẞ` is its capital.
|
||||
t.set("A6", "=LET(ß,100,ẞ*2)");
|
||||
assert_eq!(t.num("A6"), 200.0);
|
||||
// Dotless ı is a different name from i.
|
||||
t.set("A7", "=LET(i,1,ı,2,i)");
|
||||
assert_eq!(t.num("A7"), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn functions_given_by_name() {
|
||||
let mut t = T::new();
|
||||
@@ -611,6 +712,36 @@ fn functions_given_by_name() {
|
||||
assert_eq!(t.num("J1"), 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn groupby_and_pivotby_take_a_lambda() {
|
||||
let mut t = T::new();
|
||||
for (i, (k, c, v)) in [("a", "x", 1), ("b", "x", 2), ("a", "y", 3)].iter().enumerate() {
|
||||
t.set(&format!("A{}", i + 1), k);
|
||||
t.set(&format!("B{}", i + 1), c);
|
||||
t.set(&format!("C{}", i + 1), &v.to_string());
|
||||
}
|
||||
t.set("E1", "=GROUPBY(A1:A3,C1:C3,LAMBDA(x,SUM(x)))");
|
||||
assert_eq!(t.get("E1"), Value::from("a"));
|
||||
assert_eq!(t.num("F1"), 4.0);
|
||||
assert_eq!(t.get("E2"), Value::from("b"));
|
||||
assert_eq!(t.num("F2"), 2.0);
|
||||
assert_eq!(t.get("E3"), Value::from("Total"));
|
||||
assert_eq!(t.num("F3"), 6.0);
|
||||
// A LET-bound lambda, called once per group with that group's values.
|
||||
t.set("H1", "=LET(f,LAMBDA(v,ROWS(v)*10),GROUPBY(A1:A3,C1:C3,f))");
|
||||
assert_eq!(t.num("I1"), 20.0);
|
||||
assert_eq!(t.num("I3"), 30.0);
|
||||
// PIVOTBY: rows a, b, Total; columns x, y, Total.
|
||||
t.set("K1", "=PIVOTBY(A1:A3,B1:B3,C1:C3,LAMBDA(x,SUM(x)))");
|
||||
assert_eq!(t.get("L1"), Value::from("x"));
|
||||
assert_eq!(t.num("L2"), 1.0);
|
||||
assert_eq!(t.num("M2"), 3.0);
|
||||
assert_eq!(t.num("N4"), 6.0);
|
||||
// Neither a function nor a lambda.
|
||||
t.set("P1", "=GROUPBY(A1:A3,C1:C3,TRUE)");
|
||||
assert_eq!(t.get("P1"), Value::Error(CellError::Value));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lambda_argument_count() {
|
||||
let mut t = T::new();
|
||||
@@ -657,6 +788,24 @@ fn names_and_sheets() {
|
||||
assert_eq!(t.num("A1"), 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_referring_to_several_ranges() {
|
||||
let mut t = T::new();
|
||||
// As Excel writes it in `<definedName>`: a union with no parentheses around it.
|
||||
let union = "Sheet1!$A$1:$A$2,Sheet1!$C$1:$C$2";
|
||||
t.wb.names.push(gridcraft_model::DefinedName { name: "u".into(), scope: None, formula: union.into(), comment: String::new(), hidden: false });
|
||||
for (at, v) in [("A1", "1"), ("A2", "2"), ("C1", "5"), ("C2", "7")] {
|
||||
t.set(at, v);
|
||||
}
|
||||
t.set("E1", "=SUM(u)");
|
||||
t.set("E2", "=AREAS(u)");
|
||||
t.set("E3", "=MIN(u)");
|
||||
t.set("E4", "=INDEX(u,2,1,2)");
|
||||
assert_eq!((t.num("E1"), t.num("E2"), t.num("E3"), t.num("E4")), (15.0, 2.0, 1.0, 7.0));
|
||||
t.set("C2", "70");
|
||||
assert_eq!(t.num("E1"), 78.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_array_formulas_fill_their_range() {
|
||||
let mut t = T::new();
|
||||
@@ -684,6 +833,34 @@ fn legacy_array_formulas_fill_their_range() {
|
||||
assert_eq!(t.get("H1"), Value::Error(CellError::Num));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn array_broadcast_result_is_capped() {
|
||||
// A full column against a full row broadcasts to 1048576 x 16384 (17 billion cells, about
|
||||
// 400 GB): each operand is within its own cap, so only the product check stops the abort.
|
||||
let mut t = T::new();
|
||||
let col = "SEQUENCE(1048576)";
|
||||
let row = "SEQUENCE(1,16384)";
|
||||
for (at, formula) in [
|
||||
("A1", format!("=ROWS({col}*{row})")),
|
||||
("A2", format!("=ROWS({col}&{row})")),
|
||||
("A3", format!("=ROWS({col}>{row})")),
|
||||
("A4", format!("=ROWS(IF({col},{row}))")),
|
||||
("A5", format!("=ROWS(IF({col}>0,1,{row}))")),
|
||||
("A6", format!("=ROWS(TEXT({col},{row}))")),
|
||||
("A7", format!("=ROWS(MAP({col},{row},LAMBDA(a,b,a)))")),
|
||||
] {
|
||||
t.set(at, &formula);
|
||||
assert_eq!(t.get(at), Value::Error(CellError::Num), "{formula}");
|
||||
}
|
||||
// Broadcasts within the cap are unchanged.
|
||||
t.set("C1", "=SUM(SEQUENCE(3)*SEQUENCE(1,4))");
|
||||
assert_eq!(t.num("C1"), 60.0);
|
||||
t.set("C2", "=SUM(IF(SEQUENCE(2)>1,SEQUENCE(1,3),0))");
|
||||
assert_eq!(t.num("C2"), 6.0);
|
||||
t.set("C3", "=COLUMNS(TEXT(SEQUENCE(2),SEQUENCE(1,5)))");
|
||||
assert_eq!(t.num("C3"), 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_function() {
|
||||
let mut t = T::new();
|
||||
@@ -702,6 +879,41 @@ fn long_operator_chains() {
|
||||
assert_eq!(t.num("B2"), 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_chains_as_long_as_excel_allows() {
|
||||
// Values observed in Excel (#916): a flat chain has no operand limit below the 8,192-character
|
||||
// formula limit, which `=1+1+…` with 4,096 operands meets exactly. On a 1 MiB stack, as on
|
||||
// the Windows main thread.
|
||||
std::thread::Builder::new()
|
||||
.stack_size(1 << 20)
|
||||
.spawn(|| {
|
||||
let chain = |op: &str, item: &str, n: usize| format!("={}", vec![item; n].join(op));
|
||||
let mut t = T::new();
|
||||
for r in 1..=1500 {
|
||||
t.set(&format!("A{r}"), "1");
|
||||
}
|
||||
t.set("B1", &chain("+", "1", 401));
|
||||
t.set("B2", &chain("+", "1", 4096));
|
||||
t.set("B3", &chain("-", "1", 2000));
|
||||
t.set("B4", &chain("*", "1", 4096));
|
||||
t.set("B5", &chain("/", "2", 4096));
|
||||
t.set("B6", &chain("^", "1", 4096));
|
||||
t.set("B7", &chain("+", "1%", 2000));
|
||||
t.set("B8", "=1=1=1");
|
||||
t.set("B9", &format!("={}", (1..=1500).map(|r| format!("A{r}")).collect::<Vec<_>>().join("+")));
|
||||
t.set("B10", &chain("&", "1", 2000));
|
||||
for (at, want) in [("B1", 401.0), ("B2", 4096.0), ("B3", -1998.0), ("B4", 1.0), ("B5", 0.0), ("B6", 1.0), ("B9", 1500.0)] {
|
||||
assert_eq!(t.num(at), want, "{at}");
|
||||
}
|
||||
assert!((t.num("B7") - 20.0).abs() < 1e-9);
|
||||
assert_eq!(t.get("B8"), Value::Bool(false));
|
||||
assert_eq!(t.get("B10"), Value::text("1".repeat(2000)));
|
||||
})
|
||||
.expect("spawn")
|
||||
.join()
|
||||
.expect("long chains must not overflow the stack");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subtotal_skips_hidden() {
|
||||
let mut t = T::new();
|
||||
@@ -738,6 +950,40 @@ fn subtotal_109_skips_nested_subtotal() {
|
||||
assert_eq!(t.num("B2"), 3.0);
|
||||
}
|
||||
|
||||
/// One value stored in the sheet's last cell makes `A:XFD` trim to ~17 billion cells. SUBTOTAL
|
||||
/// and AGGREGATE used to read every one of them into a vector (an abort on open, #2175); now
|
||||
/// every argument is charged against `MAX_CELLS` before any cell is read, and `#NUM!` comes back.
|
||||
#[test]
|
||||
fn subtotal_and_aggregate_cap_cells_visited() {
|
||||
let mut t = T::new();
|
||||
t.set("XFD1048576", "1");
|
||||
let formulas = [
|
||||
"=SUBTOTAL(9,A:XFD)",
|
||||
"=SUBTOTAL(109,1:1048576)",
|
||||
"=AGGREGATE(9,6,A:XFD)",
|
||||
"=AGGREGATE(14,7,A:XFD,1)",
|
||||
// A:J and K:T are about 10.5 million cells each: under the limit alone, over it together,
|
||||
// so the second area is refused before the first is read.
|
||||
"=SUBTOTAL(9,A:J,K:T)",
|
||||
"=AGGREGATE(9,4,(A:J,K:T))",
|
||||
];
|
||||
for (i, f) in formulas.iter().enumerate() {
|
||||
let at = format!("B{}", i + 1);
|
||||
t.set(&at, f);
|
||||
assert_eq!(t.get(&at), Value::Error(CellError::Num), "{f}");
|
||||
}
|
||||
|
||||
// Whole columns and rows still clip to the used range when it is small.
|
||||
let mut t = T::new();
|
||||
t.set("A1", "1");
|
||||
t.set("B2", "2");
|
||||
t.set("C2", "#N/A");
|
||||
t.set("E5", "=SUBTOTAL(9,A:B)");
|
||||
t.set("E6", "=AGGREGATE(9,6,1:2)");
|
||||
t.set("E7", "=AGGREGATE(14,6,A:C,1)");
|
||||
assert_eq!((t.num("E5"), t.num("E6"), t.num("E7")), (3.0, 3.0, 2.0));
|
||||
}
|
||||
|
||||
/// SUBTOTAL leaves out rows a filter hides for every function number, while 1-11 keep rows
|
||||
/// hidden by hand (as in ConditionalFormattingSamples.xlsx, where `SUBTOTAL(9,Table69[Sales])`
|
||||
/// under a filtered table adds only the visible rows).
|
||||
@@ -1266,3 +1512,272 @@ fn offset_extreme_arguments_return_ref() {
|
||||
assert_eq!(crate::evaluate(&wb, 0, c("D1"), formula), Value::Number(expected), "{formula}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A formula reading one further down (or on a later sheet) is calculated on demand, inside the
|
||||
/// formula that reads it. Chains of thousands of such formulas, common in financial models, used
|
||||
/// to overflow the stack (or give `#CIRC!` past 2,000 levels); they now calculate in full.
|
||||
#[test]
|
||||
fn long_dependency_chains_against_row_order_calculate() {
|
||||
const N: u32 = 20_000;
|
||||
let mut t = T::new();
|
||||
{
|
||||
let sh = t.wb.sheet_mut(0).unwrap();
|
||||
for r in 1..N {
|
||||
sh.cells.set(c(&format!("A{r}")), Cell::formula(Formula::new(&format!("=A{}+1", r + 1))));
|
||||
}
|
||||
sh.cells.set(c(&format!("A{N}")), Cell::value(Value::Number(1.0)));
|
||||
}
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
assert_eq!(t.num("A1"), N as f64);
|
||||
assert_eq!(t.num("A2"), (N - 1) as f64);
|
||||
assert!(t.calc.circular.is_empty());
|
||||
// An edit at the far end recalculates the whole chain.
|
||||
t.set(&format!("A{N}"), "10");
|
||||
assert_eq!(t.num("A1"), (N + 9) as f64);
|
||||
assert!(t.calc.circular.is_empty());
|
||||
|
||||
// Hopping between two sheets at every step, through a range read (SUM) as well.
|
||||
let mut t = T::new();
|
||||
t.wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("Data")));
|
||||
for r in 1..N {
|
||||
t.wb.sheet_mut(0).unwrap().cells.set(c(&format!("A{r}")), Cell::formula(Formula::new(&format!("=Data!A{r}+1"))));
|
||||
let next = format!("=SUM(Sheet1!A{}:A{})", r + 1, r + 1);
|
||||
t.wb.sheet_mut(1).unwrap().cells.set(c(&format!("A{r}")), Cell::formula(Formula::new(&next)));
|
||||
}
|
||||
t.wb.sheet_mut(0).unwrap().cells.set(c(&format!("A{N}")), Cell::value(Value::Number(0.0)));
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
assert_eq!(t.num("A1"), (N - 1) as f64);
|
||||
assert_eq!(t.wb.sheet(1).unwrap().value(c("A1")), Value::Number((N - 2) as f64));
|
||||
assert!(t.calc.circular.is_empty());
|
||||
}
|
||||
|
||||
/// A loop longer than the on-demand nesting limit is still a circular reference (and still ends).
|
||||
#[test]
|
||||
fn long_circular_chain_is_circular() {
|
||||
const N: u32 = 5_000;
|
||||
let mut t = T::new();
|
||||
{
|
||||
let sh = t.wb.sheet_mut(0).unwrap();
|
||||
for r in 1..N {
|
||||
sh.cells.set(c(&format!("A{r}")), Cell::formula(Formula::new(&format!("=A{}+1", r + 1))));
|
||||
}
|
||||
sh.cells.set(c(&format!("A{N}")), Cell::formula(Formula::new("=A1+1")));
|
||||
sh.cells.set(c("B1"), Cell::formula(Formula::new("=1+2")));
|
||||
}
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
assert_eq!(t.get("A1"), Value::Error(CellError::Circ));
|
||||
assert_eq!(t.get(&format!("A{N}")), Value::Error(CellError::Circ));
|
||||
assert_eq!(t.num("B1"), 3.0);
|
||||
assert!(!t.calc.circular.is_empty());
|
||||
// Each formula is listed once at most.
|
||||
let mut seen = t.calc.circular.clone();
|
||||
seen.sort_by_key(|(s, c)| (*s, c.row, c.col));
|
||||
seen.dedup();
|
||||
assert_eq!(seen.len(), t.calc.circular.len());
|
||||
// With iterative calculation the loop settles to numbers instead (no hang, no crash).
|
||||
t.wb.calc.iterative = true;
|
||||
t.wb.calc.max_iterations = 3;
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
assert!(t.get("A1").as_f64().is_some());
|
||||
}
|
||||
|
||||
/// A loop closing back into the middle of a chain deeper than the on-demand nesting limit is found
|
||||
/// where calculating it in one go finds it: at the formula read while it was being calculated.
|
||||
#[test]
|
||||
fn deep_loop_is_found_where_it_closes() {
|
||||
let mut t = T::new();
|
||||
{
|
||||
let sh = t.wb.sheet_mut(0).unwrap();
|
||||
for r in 1..300 {
|
||||
sh.cells.set(c(&format!("A{r}")), Cell::formula(Formula::new(&format!("=A{}+1", r + 1))));
|
||||
}
|
||||
sh.cells.set(c("A300"), Cell::formula(Formula::new("=A30+1")));
|
||||
// Reads the loop through IFERROR, so its value shows where the loop was cut.
|
||||
sh.cells.set(c("B1"), Cell::formula(Formula::new("=IFERROR(A1,-1)")));
|
||||
}
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
assert_eq!(t.calc.circular, vec![(0, c("A30"))]);
|
||||
assert_eq!(t.get("A1"), Value::Error(CellError::Circ));
|
||||
assert_eq!(t.num("B1"), -1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_bounded_references_keep_their_shape() {
|
||||
// D3:AZ400 is 19,502 cells, past the size where a block used to be cut to the used range
|
||||
// (which ends at column F here). Sliced that way it no longer lined up with the full-size
|
||||
// operands next to it. Expected values observed in Excel 365 on the same layout.
|
||||
let mut t = T::new();
|
||||
for (at, v) in [
|
||||
("D1", "1"),
|
||||
("E1", "2"),
|
||||
("F1", "3"),
|
||||
("A3", "1"),
|
||||
("A4", "2"),
|
||||
("A5", "1"),
|
||||
("B3", "2"),
|
||||
("B4", "3"),
|
||||
("B5", "4"),
|
||||
("D3", "10"),
|
||||
("E3", "20"),
|
||||
("F3", "30"),
|
||||
("D4", "40"),
|
||||
("E4", "50"),
|
||||
("F4", "60"),
|
||||
("D5", "70"),
|
||||
("E5", "80"),
|
||||
("F5", "90"),
|
||||
] {
|
||||
t.set(at, v);
|
||||
}
|
||||
let cases = [
|
||||
("=SUMPRODUCT(D3:AZ400*B3:B400,(A3:A400=1)*(D1:AZ1=2))", Value::Number(360.0)),
|
||||
("=ROWS(D3:AZ400*1)", Value::Number(398.0)),
|
||||
("=COLUMNS(D3:AZ400*1)", Value::Number(49.0)),
|
||||
("=SUMPRODUCT(D3:AZ400,(A3:A400=1)*(D1:AZ1=2))", Value::Number(100.0)),
|
||||
("=SUMPRODUCT(D3:AZ400+0,D3:AZ401)", Value::Error(CellError::Value)),
|
||||
("=COUNTBLANK(D3:AZ400)", Value::Number(19493.0)),
|
||||
("=SUMPRODUCT(--(D3:AZ400=\"\"))", Value::Number(19493.0)),
|
||||
("=SUM(D3:AZ400*B3:B400)", Value::Number(1530.0)),
|
||||
("=COLUMNS(TRANSPOSE(D3:AZ400))", Value::Number(398.0)),
|
||||
// Whole columns are still read up to the used range.
|
||||
("=SUMPRODUCT((A:A=1)*B:B)", Value::Number(6.0)),
|
||||
];
|
||||
for (i, (formula, want)) in cases.iter().enumerate() {
|
||||
let at = format!("C{}", 1001 + i);
|
||||
t.set(&at, formula);
|
||||
assert_eq!(&t.get(&at), want, "{formula}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn median_large_values_recalculate_dependents() {
|
||||
let mut t = T::new();
|
||||
t.set("A1", "1e308");
|
||||
t.set("A2", "1.6e308");
|
||||
t.set("B1", "=MEDIAN(A1:A2)");
|
||||
t.set("B2", "=AGGREGATE(12,0,A1:A2)");
|
||||
t.set("C1", "=B1/1e308");
|
||||
t.set("C2", "=B2/1e308");
|
||||
for cell in ["B1", "B2"] {
|
||||
assert!((t.num(cell) / 1e308 - 1.3).abs() < 1e-14);
|
||||
}
|
||||
for cell in ["C1", "C2"] {
|
||||
assert!((t.num(cell) - 1.3).abs() < 1e-14);
|
||||
}
|
||||
t.set("A2", "-1e308");
|
||||
for cell in ["B1", "B2", "C1", "C2"] {
|
||||
assert_eq!(t.num(cell), 0.0);
|
||||
}
|
||||
t.set("A1", "-1.6e308");
|
||||
for cell in ["B1", "B2"] {
|
||||
assert!((t.num(cell) / 1e308 + 1.3).abs() < 1e-14);
|
||||
}
|
||||
for cell in ["C1", "C2"] {
|
||||
assert!((t.num(cell) + 1.3).abs() < 1e-14);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_filename_names_the_workbook_file() {
|
||||
// Excel returns `<folder>/[<file>]<sheet>`, and the classic sheet-name formula splits it on
|
||||
// the `]`; an empty string broke that formula in every German workbook that used it (#1489).
|
||||
let dir = std::path::Path::new("books");
|
||||
let mut t = T::new();
|
||||
t.wb.sheet_mut(0).unwrap().name = "Info".into();
|
||||
|
||||
// No file yet: empty, as in a workbook Excel has never saved.
|
||||
t.set("A1", "1");
|
||||
t.set("B1", "=CELL(\"filename\",A1)");
|
||||
assert_eq!(t.get("B1"), Value::from(""));
|
||||
|
||||
t.wb.source_path = Some(dir.join("gc_cell_info_de.xlsx"));
|
||||
t.calc = Calc::new();
|
||||
let expect = dir.join("[gc_cell_info_de.xlsx]Info").to_string_lossy().into_owned();
|
||||
|
||||
t.set("B2", "=CELL(\"filename\",A1)");
|
||||
assert_eq!(t.get("B2"), Value::from(expect.as_str()));
|
||||
|
||||
// The two formulas from the report: the sheet name, and the test that the `]` is there.
|
||||
t.set("B3", "=MID(CELL(\"filename\",A1),FIND(\"]\",CELL(\"filename\",A1))+1,31)");
|
||||
assert_eq!(t.get("B3"), Value::from("Info"), "the sheet-name formula");
|
||||
t.set("B4", "=ISNUMBER(FIND(\"]\",CELL(\"filename\",A1)))");
|
||||
assert_eq!(t.get("B4"), Value::Bool(true));
|
||||
// The sheet is the one the reference is on, not the active one.
|
||||
t.wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("Second")));
|
||||
t.set("B6", "=CELL(\"filename\",Second!A1)");
|
||||
assert_eq!(t.get("B6"), Value::from(dir.join("[gc_cell_info_de.xlsx]Second").to_string_lossy().into_owned().as_str()));
|
||||
|
||||
// A workbook that arrived as bytes knows its name but no folder: still splittable.
|
||||
t.wb.source_path = Some(std::path::PathBuf::from("Book.xlsx"));
|
||||
t.calc = Calc::new();
|
||||
t.set("B7", "=CELL(\"filename\",A1)");
|
||||
assert_eq!(t.get("B7"), Value::from("[Book.xlsx]Info"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn n_lifts_over_a_computed_array_but_reads_the_first_cell_of_a_range() {
|
||||
let mut t = T::new();
|
||||
t.set("B1", "0");
|
||||
t.set("B2", "23850");
|
||||
t.set("D2", "10%");
|
||||
t.set("D3", "12%");
|
||||
t.set("F1", "50000");
|
||||
t.set("H1", "=SUMPRODUCT(D2:D3-D1:D2,F1-B1:B2,N(F1>B1:B2))");
|
||||
assert!((t.num("H1") - 5523.0).abs() < 1e-9, "{:?}", t.get("H1"));
|
||||
t.set("H2", "=SUMPRODUCT(N(B1:B2>0))");
|
||||
assert_eq!(t.num("H2"), 1.0);
|
||||
// A spill of N(D2:D3) would also show 10% in H3; summing it tells the two apart.
|
||||
t.set("H3", "=SUM(N(D2:D3))");
|
||||
assert!((t.num("H3") - 0.1).abs() < 1e-12, "{:?}", t.get("H3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dynamic_array_of_one_value_still_anchors_a_spill() {
|
||||
// In Excel a dynamic-array formula anchors a spill range even while its result is a single
|
||||
// value, so `C1#` is the 1 x 1 range and ROWS/SUM/COUNT of it work. GridCraft gave #REF!
|
||||
// (and COUNT 0), so every formula counting a filtered list broke the moment the list had
|
||||
// exactly one entry (#1491). Values and cells follow the reported fixture.
|
||||
let mut t = T::new();
|
||||
for i in 1..=5 {
|
||||
t.set(&format!("A{i}"), &i.to_string());
|
||||
t.set(&format!("B{i}"), "7");
|
||||
}
|
||||
t.set("C1", "=FILTER(A1:A5,A1:A5>4)");
|
||||
t.set("C3", "=SORT(FILTER(A1:A5,A1:A5=3))");
|
||||
t.set("C5", "=UNIQUE(B1:B5)");
|
||||
// Control: the same FILTER matching two rows, which worked before.
|
||||
t.set("C9", "=FILTER(A1:A5,A1:A5>3)");
|
||||
|
||||
t.set("E1", "=ROWS(C1#)");
|
||||
t.set("E2", "=SUM(C1#)");
|
||||
t.set("E3", "=ROWS(C3#)");
|
||||
t.set("E5", "=COUNT(C5#)");
|
||||
t.set("E9", "=ROWS(C9#)");
|
||||
assert_eq!(t.get("E1"), Value::from(1.0));
|
||||
assert_eq!(t.get("E2"), Value::from(5.0));
|
||||
assert_eq!(t.get("E3"), Value::from(1.0));
|
||||
assert_eq!(t.get("E5"), Value::from(1.0));
|
||||
assert_eq!(t.get("E9"), Value::from(2.0), "a spilling array is unchanged");
|
||||
|
||||
// The anchors still show their first value, as they did.
|
||||
assert_eq!(t.get("C1"), Value::from(5.0));
|
||||
assert_eq!(t.get("C5"), Value::from(7.0));
|
||||
|
||||
// A formula whose result is a scalar never becomes an anchor: `#` on it is still #REF!.
|
||||
t.set("D1", "=1+1");
|
||||
t.set("E11", "=ROWS(D1#)");
|
||||
assert_eq!(t.get("E11"), Value::Error(CellError::Ref));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_before_after_empty_instance_defaults_to_one() {
|
||||
let mut t = T::new();
|
||||
t.set("A1", "=TEXTBEFORE(\"Little Red Riding Hood\",\"red\",,1)");
|
||||
assert_eq!(t.get("A1"), Value::Text("Little ".into()));
|
||||
t.set("A2", "=TEXTAFTER(\"a-b\",\"-\",,,,\"nf\")");
|
||||
assert_eq!(t.get("A2"), Value::Text("b".into()));
|
||||
t.set("A3", "=TEXTAFTER(\"a-b\",\"x\",,,,\"nf\")");
|
||||
assert_eq!(t.get("A3"), Value::Text("nf".into()));
|
||||
t.set("A4", "=TEXTBEFORE(\"a-b\",\"-\",0)");
|
||||
assert_eq!(t.get("A4"), Value::Error(CellError::Value));
|
||||
}
|
||||
|
||||
@@ -123,6 +123,11 @@ impl Measure for ApproxMeasure {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ChartData {
|
||||
pub categories: Vec<String>,
|
||||
/// Outer levels of a multi-level category axis (a category range two columns wide beside a
|
||||
/// column of values: year beside month), nearest the labels first. One entry per category,
|
||||
/// non-empty where a group starts: `["2023", "", "", "2024", "", ""]`.
|
||||
#[serde(default)]
|
||||
pub category_levels: Vec<Vec<String>>,
|
||||
pub series: Vec<SeriesData>,
|
||||
/// Workbook-theme colours for the points of pie-like charts, repeating (point `j` uses entry `j % len`); empty uses the default theme.
|
||||
#[serde(default)]
|
||||
|
||||
+121
-20
@@ -3,7 +3,7 @@
|
||||
use std::collections::HashSet;
|
||||
use std::f32::consts::{PI, TAU};
|
||||
|
||||
use gridcraft_model::{Chart, ChartKind, LegendPos, LineDash, Theme, TrendlineKind};
|
||||
use gridcraft_model::{Chart, ChartKind, LegendPos, LineDash, QuartileMethod, Theme, TrendlineKind};
|
||||
|
||||
use crate::axis::{Fmt, Scale, clean, fixed_scale, nice_scale};
|
||||
use crate::resolve::{MAX_POINTS, MAX_SERIES, MAX_TRENDLINES, series_color};
|
||||
@@ -192,6 +192,7 @@ fn sanitize(d: &ChartData) -> ChartData {
|
||||
ChartData {
|
||||
palette: d.palette.iter().take(MAX_POINTS).copied().collect(),
|
||||
categories: d.categories.iter().take(MAX_POINTS).cloned().collect(),
|
||||
category_levels: d.category_levels.iter().take(3).map(|l| l.iter().take(MAX_POINTS).cloned().collect()).collect(),
|
||||
series: d
|
||||
.series
|
||||
.iter()
|
||||
@@ -391,10 +392,20 @@ fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos: LegendPos
|
||||
// ---------------------------------------------------------------- axes frame
|
||||
|
||||
enum CatAxis<'a> {
|
||||
Labels(&'a [String]),
|
||||
/// 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>]),
|
||||
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)> {
|
||||
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()
|
||||
}
|
||||
|
||||
struct ValAxis<'a> {
|
||||
scale: Scale,
|
||||
fmt: &'a Fmt,
|
||||
@@ -420,7 +431,11 @@ fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis
|
||||
(None, CatAxis::Numeric(s, f)) => (ctx.tw(&f.format(s.max, Some(s.step)), ls) / 2.0).min(area.w * 0.2),
|
||||
_ => 4.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 };
|
||||
let levels = match &cat {
|
||||
CatAxis::Labels(_, o) => o.len() as f32,
|
||||
CatAxis::Numeric(..) => 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() {
|
||||
return plot;
|
||||
}
|
||||
@@ -441,7 +456,7 @@ fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis
|
||||
let y0 = plot.bottom() - y.scale.frac(zero) * plot.h;
|
||||
ctx.hline(plot.x, plot.right(), y0, AXIS, 0.75);
|
||||
match cat {
|
||||
CatAxis::Labels(l) => {
|
||||
CatAxis::Labels(l, outer) => {
|
||||
let n = l.len().max(1);
|
||||
let cw = plot.w / n as f32;
|
||||
let lw = l.iter().take(2000).map(|t| ctx.tw(t, ls)).fold(0.0f32, f32::max);
|
||||
@@ -450,6 +465,22 @@ fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis
|
||||
let t = ctx.fit(t, ls, cw * k as f32 - 2.0);
|
||||
ctx.text(plot.x + (j as f32 + 0.5) * cw, plot.bottom() + 3.0, &t, ls, TEXT, HAlign::Center, VAlign::Top);
|
||||
}
|
||||
// Outer levels, one row each below the labels: a group's name centred under its
|
||||
// categories, and a separator at each group boundary down through the rows.
|
||||
for (i, level) in outer.iter().enumerate() {
|
||||
let top = plot.bottom() + 3.0 + lh * (i as f32 + 1.0);
|
||||
for (first, end) in level_groups(level, n) {
|
||||
let (x0, x1) = (plot.x + first as f32 * cw, plot.x + end as f32 * cw);
|
||||
if let Some(t) = level.get(first).filter(|t| !t.is_empty()) {
|
||||
let t = ctx.fit(t, ls, x1 - x0 - 2.0);
|
||||
ctx.text((x0 + x1) / 2.0, top, &t, ls, TEXT, HAlign::Center, VAlign::Top);
|
||||
}
|
||||
if first > 0 {
|
||||
ctx.vline(x0, plot.bottom(), top + lh - 2.0, AXIS, 0.75);
|
||||
}
|
||||
}
|
||||
ctx.vline(plot.right(), plot.bottom(), top + lh - 2.0, AXIS, 0.75);
|
||||
}
|
||||
}
|
||||
CatAxis::Numeric(s, f) => {
|
||||
let ticks = s.ticks();
|
||||
@@ -469,13 +500,17 @@ fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis
|
||||
}
|
||||
plot
|
||||
} else {
|
||||
let labels: Vec<String> = match cat {
|
||||
CatAxis::Labels(l) => l.to_vec(),
|
||||
CatAxis::Numeric(..) => vec![],
|
||||
let (labels, outer): (Vec<String>, &[Vec<String>]) = match cat {
|
||||
CatAxis::Labels(l, o) => (l.to_vec(), o),
|
||||
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);
|
||||
// Outer levels stand in columns left of the labels, each as wide as its longest name.
|
||||
let level_w: Vec<f32> =
|
||||
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, y: area.y + 2.0, w: area.w - lw - right, h: area.h - lh - 6.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 };
|
||||
if !plot.ok() {
|
||||
return plot;
|
||||
}
|
||||
@@ -501,6 +536,24 @@ fn frame(ctx: &mut Ctx, area: Bx, cat: CatAxis, y: &ValAxis, y2: Option<&ValAxis
|
||||
let t = ctx.fit(t, ls, lw - 6.0);
|
||||
ctx.text(plot.x - 4.0, plot.bottom() - (j as f32 + 0.5) * ch, &t, ls, TEXT, HAlign::Right, VAlign::Middle);
|
||||
}
|
||||
// Outer levels: a group's name centred beside its categories, separators at the
|
||||
// boundaries running right to the axis.
|
||||
let mut x1 = plot.x - lw;
|
||||
for (level, w) in outer.iter().zip(&level_w) {
|
||||
let x0 = x1 - w;
|
||||
for (first, end) in level_groups(level, n) {
|
||||
let (y0, y1) = (plot.bottom() - end as f32 * ch, plot.bottom() - first as f32 * ch);
|
||||
if let Some(t) = level.get(first).filter(|t| !t.is_empty()) {
|
||||
let t = ctx.fit(t, ls, w - 4.0);
|
||||
ctx.text((x0 + x1) / 2.0, (y0 + y1) / 2.0, &t, ls, TEXT, HAlign::Center, VAlign::Middle);
|
||||
}
|
||||
if first > 0 {
|
||||
ctx.hline(x0, plot.x, y1, AXIS, 0.75);
|
||||
}
|
||||
}
|
||||
ctx.hline(x0, plot.x, plot.y, AXIS, 0.75);
|
||||
x1 = x0;
|
||||
}
|
||||
plot
|
||||
}
|
||||
}
|
||||
@@ -658,6 +711,13 @@ fn forecast(v: f64) -> f64 {
|
||||
if v.is_finite() && v > 0.0 { v.min(1e9) } else { 0.0 }
|
||||
}
|
||||
|
||||
/// Most empty category slots a forward forecast may add after `ncat` categories: four times the
|
||||
/// data (at least 32), and never past `MAX_CATS` in total. A larger forecast would shrink every
|
||||
/// column to nothing.
|
||||
fn max_forward_slots(ncat: usize) -> f64 {
|
||||
(ncat.saturating_mul(4).max(32)).min(MAX_CATS.saturating_sub(ncat)) as f64
|
||||
}
|
||||
|
||||
/// Longest forward forecast among `s`'s regression trendlines.
|
||||
fn max_forward(s: &SeriesData) -> f64 {
|
||||
s.trendlines.iter().filter(|t| !matches!(t.spec.kind, TrendlineKind::MovingAverage(_))).map(|t| forecast(t.spec.forward)).fold(0.0, f64::max)
|
||||
@@ -748,6 +808,7 @@ fn draw_trendlines(
|
||||
frac: &dyn Fn(f64, f64) -> (f64, f64),
|
||||
px: &dyn Fn((f64, f64)) -> [f32; 2],
|
||||
labels: &mut Vec<String>,
|
||||
window: (f64, f64),
|
||||
) {
|
||||
let Some((xlo, xhi)) = value_range(pts.iter().map(|p| p.0)) else { return };
|
||||
for t in s.trendlines.iter().take(MAX_TRENDLINES) {
|
||||
@@ -756,7 +817,8 @@ fn draw_trendlines(
|
||||
TrendlineKind::MovingAverage(p) => moving_average(pts, p).into_iter().map(|(x, y)| Some(frac(x, y))).collect(),
|
||||
_ => {
|
||||
let Some(fit) = fit_trendline(spec, pts) else { continue };
|
||||
let (lo, hi) = (xlo - forecast(spec.backward), xhi + forecast(spec.forward));
|
||||
// Sample only the part of the forecast that is on the plot.
|
||||
let (lo, hi) = ((xlo - forecast(spec.backward)).max(window.0), (xhi + forecast(spec.forward)).min(window.1));
|
||||
if spec.display_equation {
|
||||
labels.push(fit.equation());
|
||||
}
|
||||
@@ -936,9 +998,12 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
|
||||
.filter(|(i, _)| kinds.get(*i).is_some_and(|k| supports_trendline(*k)))
|
||||
.map(|(_, s)| max_forward(s))
|
||||
.fold(0.0, f64::max);
|
||||
let ncat_slots = ncat.saturating_add(fwd.ceil() as usize).min(MAX_CATS.max(ncat));
|
||||
let ncat_slots = ncat.saturating_add(fwd.min(max_forward_slots(ncat)).ceil() as usize).min(MAX_CATS.max(ncat));
|
||||
let labels: Vec<String> = (0..ncat_slots).map(|j| if j < ncat { cat_label(data, j) } else { String::new() }).collect();
|
||||
let plot = frame(ctx, area, CatAxis::Labels(&labels), &y1, y2.as_ref(), horizontal, chart.gridlines);
|
||||
// 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);
|
||||
if !plot.ok() {
|
||||
return;
|
||||
}
|
||||
@@ -1102,7 +1167,7 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
|
||||
[plot.x + fx as f32 * plot.w, plot.bottom() - fy as f32 * plot.h]
|
||||
}
|
||||
};
|
||||
draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels);
|
||||
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);
|
||||
}
|
||||
@@ -1196,7 +1261,7 @@ fn scatter(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
let pts: Vec<(f64, f64)> = pts.iter().flatten().copied().collect();
|
||||
let frac = |x: f64, y: f64| ((x - xs.min) / (xs.max - xs.min), (y - ys.min) / (ys.max - ys.min));
|
||||
let px = |(fx, fy): (f64, f64)| [plot.x + fx as f32 * plot.w, plot.bottom() - fy as f32 * plot.h];
|
||||
draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels);
|
||||
draw_trendlines(ctx, s, &pts, &frac, &px, &mut trend_labels, (xs.min, xs.max));
|
||||
}
|
||||
draw_trend_labels(ctx, plot, &trend_labels);
|
||||
}
|
||||
@@ -1236,7 +1301,7 @@ fn histogram(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
let maxc = counts.iter().copied().max().unwrap_or(0) as f64;
|
||||
let ys = nice_scale(0.0, maxc, true);
|
||||
let fc = Fmt::new("General");
|
||||
let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &fc }, None, false, chart.gridlines);
|
||||
let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &fc }, None, false, chart.gridlines);
|
||||
if !plot.ok() {
|
||||
return;
|
||||
}
|
||||
@@ -1288,7 +1353,7 @@ fn waterfall(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
let (lo, hi) = value_range(bars.iter().flat_map(|(b, t, _)| [*b, *t])).unwrap_or((0.0, 0.0));
|
||||
let ys = nice_scale(lo, hi, true);
|
||||
let f = Fmt::new(&s.number_format);
|
||||
let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
|
||||
let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
|
||||
if !plot.ok() {
|
||||
return;
|
||||
}
|
||||
@@ -1561,12 +1626,16 @@ fn radar(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
|
||||
// ---------------------------------------------------------------- box & whisker
|
||||
|
||||
fn quantile(sorted: &[f64], q: f64) -> f64 {
|
||||
fn quantile(sorted: &[f64], q: f64, method: QuartileMethod) -> f64 {
|
||||
let n = sorted.len();
|
||||
if n == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let pos = q * (n - 1) as f64;
|
||||
// Clamp exclusive ranks for short samples so one or two points remain drawable.
|
||||
let pos = match method {
|
||||
QuartileMethod::Inclusive => q * (n - 1) as f64,
|
||||
QuartileMethod::Exclusive => (q * (n as f64 + 1.0) - 1.0).clamp(0.0, (n - 1) as f64),
|
||||
};
|
||||
let i = pos.floor() as usize;
|
||||
let frac = pos - i as f64;
|
||||
let a = sorted.get(i).copied().unwrap_or(0.0);
|
||||
@@ -1579,7 +1648,7 @@ fn box_whisker(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return };
|
||||
let ys = nice_scale(lo, hi, false);
|
||||
let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General"));
|
||||
let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
|
||||
let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
|
||||
if !plot.ok() {
|
||||
return;
|
||||
}
|
||||
@@ -1593,7 +1662,8 @@ fn box_whisker(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
continue;
|
||||
}
|
||||
v.sort_by(f64::total_cmp);
|
||||
let (q1, med, q3) = (quantile(&v, 0.25), quantile(&v, 0.5), quantile(&v, 0.75));
|
||||
let method = chart.series.get(i).map(|s| s.quartiles).unwrap_or_default();
|
||||
let (q1, med, q3) = (quantile(&v, 0.25, method), quantile(&v, 0.5, method), quantile(&v, 0.75, method));
|
||||
let iqr = q3 - q1;
|
||||
let (flo, fhi) = (q1 - 1.5 * iqr, q3 + 1.5 * iqr);
|
||||
let wlo = v.iter().copied().find(|x| *x >= flo).unwrap_or(q1);
|
||||
@@ -1627,7 +1697,7 @@ fn stock(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
let Some((lo, hi)) = value_range(data.series.iter().flat_map(|s| s.values.iter().flatten().copied())) else { return };
|
||||
let ys = nice_scale(lo, hi, false);
|
||||
let f = Fmt::new(data.series.first().map(|s| s.number_format.as_str()).unwrap_or("General"));
|
||||
let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
|
||||
let plot = frame(ctx, area, CatAxis::Labels(&labels, &[]), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
|
||||
if !plot.ok() {
|
||||
return;
|
||||
}
|
||||
@@ -1769,3 +1839,34 @@ mod smooth_tests {
|
||||
assert!(out.iter().all(|p| (0.0..=300.0).contains(&p[0]) && (10.0..=110.0).contains(&p[1])));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod quartile_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn box_whisker_default_quartiles_are_exclusive() {
|
||||
let values = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
|
||||
assert_eq!(quantile(&values, 0.25, QuartileMethod::default()), 1.75);
|
||||
assert_eq!(quantile(&values, 0.5, QuartileMethod::default()), 3.5);
|
||||
assert_eq!(quantile(&values, 0.75, QuartileMethod::default()), 5.25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quartile_methods_and_short_samples() {
|
||||
for (values, exclusive, inclusive) in [
|
||||
(vec![1., 2., 3.], (1., 3.), (1.5, 2.5)),
|
||||
(vec![1., 2., 3., 4.], (1.25, 3.75), (1.75, 3.25)),
|
||||
(vec![1., 2., 3., 4., 5.], (1.5, 4.5), (2., 4.)),
|
||||
(vec![1., 2., 3., 4., 5., 6., 7.], (2., 6.), (2.5, 5.5)),
|
||||
(vec![1., 1., 2., 3., 5., 8.], (1., 5.75), (1.25, 4.5)),
|
||||
(vec![1., 2.], (1., 2.), (1.25, 1.75)),
|
||||
(vec![1.], (1., 1.), (1., 1.)),
|
||||
(vec![], (0., 0.), (0., 0.)),
|
||||
] {
|
||||
for (method, expected) in [(QuartileMethod::Exclusive, exclusive), (QuartileMethod::Inclusive, inclusive)] {
|
||||
assert_eq!((quantile(&values, 0.25, method), quantile(&values, 0.75, method)), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+42
-10
@@ -1,7 +1,7 @@
|
||||
//! Pulls chart data (names, categories, values, formats, colours) out of a workbook.
|
||||
|
||||
use gridcraft_calc::evaluate;
|
||||
use gridcraft_core::{CellRef, RangeRef, Value, number_to_text};
|
||||
use gridcraft_core::{CellRef, RangeRef, Value, category_levels, number_to_text};
|
||||
use gridcraft_model::{Chart, ChartKind, Theme, Workbook, style::apply_tint};
|
||||
use gridcraft_numfmt::{NumberFormat, format_value};
|
||||
|
||||
@@ -41,7 +41,7 @@ fn parse_ref(wb: &Workbook, sheet: usize, formula: &str) -> Option<(usize, Range
|
||||
let (sh, addr) = match f.rfind('!') {
|
||||
Some(i) => {
|
||||
let name = f.get(..i)?.trim();
|
||||
let name = name.strip_prefix('\'').and_then(|n| n.strip_suffix('\'')).map(|n| n.replace("''", "'")).unwrap_or_else(|| name.to_string());
|
||||
let name = gridcraft_core::unquote_sheet(name);
|
||||
(wb.sheet_index(&name)?, f.get(i + 1..)?)
|
||||
}
|
||||
None => (sheet, f),
|
||||
@@ -133,12 +133,16 @@ fn value_text(v: &Value) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Category labels, formatted with each source cell's number format (dates, currency…).
|
||||
fn category_texts(wb: &Workbook, sheet: usize, formula: &str) -> Vec<String> {
|
||||
/// Category labels, formatted with each source cell's number format (dates, currency…), and
|
||||
/// the outer levels of a multi-level axis: a category range wider than a column of values (or
|
||||
/// taller than a row of values) groups its innermost labels, year beside month. Each outer level
|
||||
/// has one entry per category, non-empty where a group starts, as the cells are.
|
||||
fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str) -> (Vec<String>, Vec<Vec<String>>) {
|
||||
let vals = eval_list(wb, sheet, formula);
|
||||
let cells: Option<(usize, RangeRef)> = parse_ref(wb, sheet, formula);
|
||||
let src = cells.and_then(|(sh, r)| wb.sheet(sh).map(|s| (s, r)));
|
||||
vals.iter()
|
||||
let texts: Vec<String> = vals
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| {
|
||||
if let (Some((s, r)), Value::Number(_)) = (src, v) {
|
||||
@@ -153,16 +157,42 @@ fn category_texts(wb: &Workbook, sheet: usize, formula: &str) -> Vec<String> {
|
||||
}
|
||||
value_text(v)
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
let Some((_, r)) = src else { return (texts, vec![]) };
|
||||
let value_range = parse_ref(wb, sheet, values).map(|(_, r)| r);
|
||||
let Some(levels) = category_levels(&r, value_range.as_ref()) else { return (texts, vec![]) };
|
||||
let (w, h) = (r.width().max(1) as usize, r.height().max(1) as usize);
|
||||
if texts.len() != w.saturating_mul(h) {
|
||||
return (texts, vec![]); // truncated or not a plain block: a flat list
|
||||
}
|
||||
let at = |row: usize, col: usize| texts.get(row * w + col).cloned().unwrap_or_default();
|
||||
let column = |c: usize| (0..h).map(|row| at(row, c)).collect::<Vec<String>>();
|
||||
let row = |rr: usize| (0..w).map(|c| at(rr, c)).collect::<Vec<String>>();
|
||||
let outer = levels.outer as usize;
|
||||
if levels.by_columns {
|
||||
(column(w - 1), (1..=outer).filter_map(|k| (w - 1).checked_sub(k)).map(column).collect())
|
||||
} else {
|
||||
(row(h - 1), (1..=outer).filter_map(|k| (h - 1).checked_sub(k)).map(row).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// A series name: a reference/formula is evaluated, a quoted or bare literal is used as is.
|
||||
/// Whether `t` names a defined name seen from `sheet` (`Name`, or `Sheet1!Name` looked up on
|
||||
/// that sheet), the way the evaluator looks names up.
|
||||
fn is_defined_name(wb: &Workbook, sheet: usize, t: &str) -> bool {
|
||||
match t.split_once('!') {
|
||||
Some((s, bare)) => wb.sheet_index(s.trim_matches('\'')).is_some_and(|i| wb.name(bare, i).is_some()),
|
||||
None => wb.name(t, sheet).is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A series name: a reference/formula or a defined name (`[0]!Title` in a file) is evaluated,
|
||||
/// a quoted or other bare literal is used as is.
|
||||
fn series_name(wb: &Workbook, sheet: usize, name: &str) -> String {
|
||||
let t = name.trim();
|
||||
if let Some(q) = t.strip_prefix('"').and_then(|q| q.strip_suffix('"')) {
|
||||
return q.replace("\"\"", "\"");
|
||||
}
|
||||
if t.starts_with('=') || parse_ref(wb, sheet, t).is_some() && t.contains('!') {
|
||||
if t.starts_with('=') || parse_ref(wb, sheet, t).is_some() && t.contains('!') || is_defined_name(wb, sheet, t) {
|
||||
let vals = eval_list(wb, sheet, t);
|
||||
let parts: Vec<String> = vals.iter().map(value_text).filter(|s| !s.is_empty()).take(8).collect();
|
||||
return parts.join(" ");
|
||||
@@ -197,6 +227,7 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
|
||||
let numeric_x = matches!(chart.kind, ChartKind::Scatter | ChartKind::ScatterLines | ChartKind::Bubble);
|
||||
let mut series = Vec::new();
|
||||
let mut categories: Option<Vec<String>> = None;
|
||||
let mut category_levels = Vec::new();
|
||||
for (i, s) in chart.series.iter().take(MAX_SERIES).enumerate() {
|
||||
let values = nums(wb, sheet, &s.values);
|
||||
let name = s.name.as_deref().map(|n| series_name(wb, sheet, n)).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Series{}", i + 1));
|
||||
@@ -211,9 +242,10 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
|
||||
if categories.is_none()
|
||||
&& let Some(f) = &s.categories
|
||||
{
|
||||
let c = category_texts(wb, sheet, f);
|
||||
let (c, outer) = category_table(wb, sheet, f, &s.values);
|
||||
if !c.is_empty() {
|
||||
categories = Some(c);
|
||||
category_levels = outer;
|
||||
}
|
||||
}
|
||||
let sizes = s.bubble_sizes.as_deref().map(|f| nums(wb, sheet, f));
|
||||
@@ -242,5 +274,5 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
|
||||
let n = series.iter().map(|s| s.values.len()).max().unwrap_or(0);
|
||||
let categories = categories.unwrap_or_else(|| (1..=n).map(|i| i.to_string()).collect());
|
||||
let palette = default_palette(&wb.theme, n.min(PALETTE_CYCLE));
|
||||
ChartData { categories, series, palette }
|
||||
ChartData { categories, category_levels, series, palette }
|
||||
}
|
||||
|
||||
+171
-13
@@ -74,6 +74,7 @@ fn sample(kind: ChartKind) -> ChartData {
|
||||
let mut b = series("East", &[38000.0, 30000.0, 45000.0, 61870.0, 52000.0, 41000.0], [200, 80, 40, 255], k2);
|
||||
b.secondary = kind == ChartKind::Combo;
|
||||
ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: cats.iter().map(|s| s.to_string()).collect(),
|
||||
series: vec![
|
||||
@@ -227,6 +228,7 @@ fn resolve_from_workbook() {
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
},
|
||||
@@ -238,6 +240,7 @@ fn resolve_from_workbook() {
|
||||
color: Some(Color::rgb(1, 2, 3)),
|
||||
secondary: true,
|
||||
kind: None,
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
},
|
||||
@@ -249,6 +252,7 @@ fn resolve_from_workbook() {
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
},
|
||||
@@ -287,6 +291,7 @@ fn resolve_from_workbook() {
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
},
|
||||
@@ -298,6 +303,7 @@ fn resolve_from_workbook() {
|
||||
color: None,
|
||||
secondary: true,
|
||||
kind: None,
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
},
|
||||
@@ -314,6 +320,7 @@ fn resolve_from_workbook() {
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
}];
|
||||
@@ -331,6 +338,7 @@ fn resolve_from_workbook() {
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
}];
|
||||
@@ -363,7 +371,8 @@ fn scatter_with_text_x_values_numbers_the_points() {
|
||||
let d = resolve(&wb, 0, &c);
|
||||
assert_eq!(d.series[0].x, None, "{kind:?}");
|
||||
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
|
||||
let empty = render(&c, &ChartData { palette: vec![], categories: vec![], series: vec![] }, 400.0, 300.0, &ApproxMeasure);
|
||||
let empty =
|
||||
render(&c, &ChartData { category_levels: vec![], palette: vec![], categories: vec![], series: vec![] }, 400.0, 300.0, &ApproxMeasure);
|
||||
assert!(prims.len() > empty.len() + 2, "{kind:?} draws its points");
|
||||
// Numeric X values are kept.
|
||||
c.series[0].categories = Some("Sheet1!$C$1:$C$3".into());
|
||||
@@ -456,8 +465,12 @@ fn pie_starts_at_twelve_clockwise() {
|
||||
#[test]
|
||||
fn negative_values_put_axis_below_zero() {
|
||||
let c = chart(ChartKind::ColumnClustered);
|
||||
let d =
|
||||
ChartData { palette: vec![], categories: vec![], series: vec![series("N", &[-5.0, 3.0, -2.0], [1, 2, 3, 255], ChartKind::ColumnClustered)] };
|
||||
let d = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: vec![],
|
||||
series: vec![series("N", &[-5.0, 3.0, -2.0], [1, 2, 3, 255], ChartKind::ColumnClustered)],
|
||||
};
|
||||
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
|
||||
let t = texts(&prims);
|
||||
assert!(t.iter().any(|s| s.starts_with('-') || s.starts_with("($") || s.starts_with("-$")), "{t:?}");
|
||||
@@ -469,11 +482,13 @@ fn edge_cases_never_panic() {
|
||||
let m = ApproxMeasure;
|
||||
let empty = ChartData::default();
|
||||
let zeros = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: vec!["a".into(), "b".into()],
|
||||
series: vec![series("z", &[0.0, 0.0], [0, 0, 0, 255], ChartKind::Line)],
|
||||
};
|
||||
let weird = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: vec![String::new(); 3],
|
||||
series: vec![SeriesData {
|
||||
@@ -505,7 +520,12 @@ fn thousands_of_points_are_decimated() {
|
||||
let vals: Vec<f64> = (0..n).map(|i| ((i as f64) * 0.01).sin() * 100.0 + i as f64 * 0.001).collect();
|
||||
let mut c = chart(ChartKind::Line);
|
||||
c.data_labels = true;
|
||||
let d = ChartData { palette: vec![], categories: vec![], series: vec![series("big", &vals, [9, 9, 9, 255], ChartKind::Line)] };
|
||||
let d = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: vec![],
|
||||
series: vec![series("big", &vals, [9, 9, 9, 255], ChartKind::Line)],
|
||||
};
|
||||
let prims = render(&c, &d, 800.0, 400.0, &ApproxMeasure);
|
||||
let max_line = prims
|
||||
.iter()
|
||||
@@ -530,7 +550,7 @@ fn smooth_line_adds_interpolated_points_and_keeps_endpoints() {
|
||||
let line_pts = |smooth: bool, m: &ApproxMeasure| -> usize {
|
||||
let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line);
|
||||
s.smooth = smooth;
|
||||
let d = ChartData { palette: vec![], categories: cats.clone(), series: vec![s] };
|
||||
let d = ChartData { category_levels: vec![], palette: vec![], categories: cats.clone(), series: vec![s] };
|
||||
render(&chart(ChartKind::Line), &d, 600.0, 400.0, m)
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
@@ -547,7 +567,7 @@ fn smooth_line_adds_interpolated_points_and_keeps_endpoints() {
|
||||
// Rendering either way must stay finite and inside the canvas.
|
||||
let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line);
|
||||
s.smooth = true;
|
||||
let d = ChartData { palette: vec![], categories: cats, series: vec![s] };
|
||||
let d = ChartData { category_levels: vec![], palette: vec![], categories: cats, series: vec![s] };
|
||||
assert_sane(&render(&chart(ChartKind::Line), &d, 600.0, 400.0, &m), 600.0, 400.0);
|
||||
}
|
||||
|
||||
@@ -557,7 +577,7 @@ fn smooth_line_splits_at_gaps() {
|
||||
let mut s = series("L", &[1.0, 5.0, 2.0, 6.0], [10, 20, 30, 255], ChartKind::Line);
|
||||
s.smooth = true;
|
||||
s.values[1] = None; // gap between two data points
|
||||
let d = ChartData { palette: vec![], categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], series: vec![s] };
|
||||
let d = ChartData { category_levels: vec![], palette: vec![], categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], series: vec![s] };
|
||||
let prims = render(&chart(ChartKind::Line), &d, 600.0, 400.0, &m);
|
||||
// Two separate runs (before/after the gap) → at least two line primitives.
|
||||
let lines = prims.iter().filter(|p| matches!(p, Prim::Line { .. })).count();
|
||||
@@ -634,7 +654,12 @@ fn fuzz_random_data_and_sizes() {
|
||||
})
|
||||
.collect();
|
||||
let ncat = (r.next() % 50) as usize;
|
||||
let d = ChartData { palette: vec![], categories: (0..ncat).map(|i| "c".repeat(i % 7) + &i.to_string()).collect(), series };
|
||||
let d = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: (0..ncat).map(|i| "c".repeat(i % 7) + &i.to_string()).collect(),
|
||||
series,
|
||||
};
|
||||
let mut c = chart(kind);
|
||||
c.legend = [LegendPos::None, LegendPos::Bottom, LegendPos::Top, LegendPos::Left, LegendPos::Right][(r.next() % 5) as usize];
|
||||
c.data_labels = r.next().is_multiple_of(2);
|
||||
@@ -809,7 +834,8 @@ fn line_chart_draws_trendline_forecast_and_labels() {
|
||||
let mut s = series("Sales", &[9.0, 7.0, 5.0, 3.0], [10, 20, 30, 255], ChartKind::Line);
|
||||
s.trendlines =
|
||||
vec![TrendlineData { spec: Trendline { forward: 1.0, display_equation: true, display_r_squared: true, ..Trendline::default() }, color: tc }];
|
||||
let d = ChartData { palette: vec![], categories: ["a", "b", "c", "d"].map(String::from).to_vec(), series: vec![s.clone()] };
|
||||
let d =
|
||||
ChartData { category_levels: vec![], palette: vec![], categories: ["a", "b", "c", "d"].map(String::from).to_vec(), series: vec![s.clone()] };
|
||||
let mut c = chart(ChartKind::Line);
|
||||
c.legend = LegendPos::None; // keeps legend swatches out of the line counts
|
||||
let prims = render(&c, &d, 600.0, 400.0, &m);
|
||||
@@ -833,7 +859,7 @@ fn line_chart_draws_trendline_forecast_and_labels() {
|
||||
assert!(lines_of(&pie, tc).is_empty());
|
||||
// One point: nothing to fit, nothing drawn.
|
||||
s.values.truncate(1);
|
||||
let one = ChartData { palette: vec![], categories: vec!["a".into()], series: vec![s] };
|
||||
let one = ChartData { category_levels: vec![], palette: vec![], categories: vec!["a".into()], series: vec![s] };
|
||||
assert!(lines_of(&render(&c, &one, 600.0, 400.0, &m), tc).is_empty());
|
||||
}
|
||||
|
||||
@@ -853,7 +879,7 @@ fn scatter_trendlines_use_x_values_and_stay_in_the_plot() {
|
||||
TrendlineData { spec: Trendline { kind: TrendlineKind::Exponential, forward: 30.0, ..Trendline::default() }, color: colors[1] },
|
||||
TrendlineData { spec: Trendline { kind: TrendlineKind::MovingAverage(2), ..Trendline::default() }, color: colors[2] },
|
||||
];
|
||||
let d = ChartData { palette: vec![], categories: vec![], series: vec![s] };
|
||||
let d = ChartData { category_levels: vec![], palette: vec![], categories: vec![], series: vec![s] };
|
||||
let mut c = chart(ChartKind::Scatter);
|
||||
c.legend = LegendPos::None;
|
||||
let prims = render(&c, &d, 600.0, 400.0, &m);
|
||||
@@ -868,6 +894,7 @@ fn scatter_trendlines_use_x_values_and_stay_in_the_plot() {
|
||||
#[test]
|
||||
fn treemap_and_histogram_shapes() {
|
||||
let d = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: vec!["a".into(), "b".into(), "c".into(), "d".into()],
|
||||
series: vec![series("t", &[6.0, 6.0, 4.0, 3.0], [10, 20, 30, 255], ChartKind::Treemap)],
|
||||
@@ -889,7 +916,12 @@ fn treemap_and_histogram_shapes() {
|
||||
assert!((rects[0] / rects[3] - 2.0).abs() < 0.05, "{rects:?}");
|
||||
|
||||
let vals: Vec<f64> = (0..100).map(|i| i as f64).collect();
|
||||
let d = ChartData { palette: vec![], categories: vec![], series: vec![series("h", &vals, [10, 20, 30, 255], ChartKind::Histogram)] };
|
||||
let d = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: vec![],
|
||||
series: vec![series("h", &vals, [10, 20, 30, 255], ChartKind::Histogram)],
|
||||
};
|
||||
let prims = render(&chart(ChartKind::Histogram), &d, 600.0, 400.0, &ApproxMeasure);
|
||||
let bins = prims.iter().filter(|p| matches!(p, Prim::Rect { fill: Some([10, 20, 30, 255]), .. })).count();
|
||||
assert_eq!(bins, 10);
|
||||
@@ -898,7 +930,12 @@ fn treemap_and_histogram_shapes() {
|
||||
#[test]
|
||||
fn histogram_boundary_values_match_interval_labels() {
|
||||
let heights = |vals: &[f64]| -> Vec<f32> {
|
||||
let d = ChartData { palette: vec![], categories: vec![], series: vec![series("h", vals, [10, 20, 30, 255], ChartKind::Histogram)] };
|
||||
let d = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: vec![],
|
||||
series: vec![series("h", vals, [10, 20, 30, 255], ChartKind::Histogram)],
|
||||
};
|
||||
let prims = render(&chart(ChartKind::Histogram), &d, 600.0, 400.0, &ApproxMeasure);
|
||||
prims.iter().filter_map(|p| if let Prim::Rect { h, fill: Some([10, 20, 30, 255]), .. } = p { Some(*h) } else { None }).collect()
|
||||
};
|
||||
@@ -967,6 +1004,7 @@ fn box_and_stock_charts_honour_the_gridlines_flag() {
|
||||
#[test]
|
||||
fn funnel_value_labels_follow_the_data_labels_flag() {
|
||||
let data = ChartData {
|
||||
category_levels: vec![],
|
||||
palette: vec![],
|
||||
categories: vec!["a".into(), "b".into(), "c".into()],
|
||||
series: vec![series("S", &[11111.0, 22222.0, 33333.0], [20, 96, 130, 255], ChartKind::Funnel)],
|
||||
@@ -1001,6 +1039,7 @@ fn pie_points_follow_workbook_theme() {
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
}];
|
||||
@@ -1057,3 +1096,122 @@ fn area_charts_draw_source_value_labels_only_when_enabled() {
|
||||
assert!(first_label > last_poly, "{kind:?}: label drawn under a later area");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_level_categories_resolve_and_draw() {
|
||||
let years = ["2023", "", "", "2024", "", ""];
|
||||
let months = ["Oct", "Nov", "Dec", "Jan", "Feb", "Mar"];
|
||||
let mut wb = Workbook::new();
|
||||
{
|
||||
let s = wb.sheet_mut(0).unwrap();
|
||||
s.name = "Sales".into();
|
||||
for (i, (y, m)) in years.iter().zip(months).enumerate() {
|
||||
// Year beside month beside the values, points down the rows…
|
||||
let r = i as u32 + 1;
|
||||
if !y.is_empty() {
|
||||
s.set_value(CellRef::new(r, 0), Value::from(*y));
|
||||
s.set_value(CellRef::new(10, i as u32), Value::from(*y));
|
||||
}
|
||||
s.set_value(CellRef::new(r, 1), Value::from(m));
|
||||
s.set_value(CellRef::new(r, 2), Value::Number(10.0 * (i as f64 + 1.0)));
|
||||
// …and the same table in rows, points along the columns.
|
||||
s.set_value(CellRef::new(11, i as u32), Value::from(m));
|
||||
s.set_value(CellRef::new(12, i as u32), Value::Number(10.0 * (i as f64 + 1.0)));
|
||||
}
|
||||
}
|
||||
let mut c = chart(ChartKind::Line);
|
||||
c.series = vec![Series { categories: Some("Sales!$A$2:$B$7".into()), values: "Sales!$C$2:$C$7".into(), ..Default::default() }];
|
||||
let d = resolve(&wb, 0, &c);
|
||||
assert_eq!(d.categories, months);
|
||||
assert_eq!(d.category_levels, vec![years.map(String::from).to_vec()]);
|
||||
|
||||
let texts = |prims: &[Prim]| -> Vec<(String, f32, f32)> {
|
||||
prims.iter().filter_map(|p| if let Prim::Text { text, x, y, .. } = p { Some((text.clone(), *x, *y)) } else { None }).collect()
|
||||
};
|
||||
let prims = render(&c, &d, 600.0, 300.0, &ApproxMeasure);
|
||||
assert_sane(&prims, 600.0, 300.0);
|
||||
let t = texts(&prims);
|
||||
let at = |t: &[(String, f32, f32)], s: &str| {
|
||||
t.iter().find(|(x, _, _)| x == s).map(|(_, x, y)| (*x, *y)).unwrap_or_else(|| panic!("no {s:?} in {t:?}"))
|
||||
};
|
||||
// Six evenly spaced month labels across the plot, not twelve slots for a two-column range.
|
||||
let xs: Vec<f32> = months.iter().map(|m| at(&t, m).0).collect();
|
||||
let step = xs[1] - xs[0];
|
||||
assert!(step > 40.0 && xs.windows(2).all(|w| (w[1] - w[0] - step).abs() < 0.5), "{xs:?}");
|
||||
// The years sit in a row below the months, each under its own group.
|
||||
let (x23, y23) = at(&t, "2023");
|
||||
let (x24, y24) = at(&t, "2024");
|
||||
assert!(y23 > at(&t, "Oct").1 && (y23 - y24).abs() < 0.01, "{t:?}");
|
||||
assert!(x23 < at(&t, "Dec").0 && x24 > at(&t, "Dec").0 && x24 < at(&t, "Mar").0, "{t:?}");
|
||||
|
||||
// Bars: the levels stand in a column left of the labels.
|
||||
c.kind = ChartKind::BarClustered;
|
||||
let prims = render(&c, &d, 600.0, 300.0, &ApproxMeasure);
|
||||
assert_sane(&prims, 600.0, 300.0);
|
||||
let t = texts(&prims);
|
||||
let (x23, y23) = at(&t, "2023");
|
||||
let (x_oct, y_oct) = at(&t, "Oct");
|
||||
// Categories run up the axis: the group's name sits left of its labels, between Oct and Dec.
|
||||
assert!(x23 < x_oct && y23 < y_oct && y23 > at(&t, "Dec").1, "{t:?}");
|
||||
let (x24, y24) = at(&t, "2024");
|
||||
assert!((x23 - x24).abs() < 0.01 && y24 < y23, "same column, 2024 above: {t:?}");
|
||||
|
||||
// A row of values: the levels are rows.
|
||||
c.series[0].categories = Some("Sales!$A$11:$F$12".into());
|
||||
c.series[0].values = "Sales!$A$13:$F$13".into();
|
||||
let d = resolve(&wb, 0, &c);
|
||||
assert_eq!(d.categories, months);
|
||||
assert_eq!(d.category_levels, vec![years.map(String::from).to_vec()]);
|
||||
|
||||
// One column of categories: a plain axis, as before.
|
||||
c.series[0].categories = Some("Sales!$B$2:$B$7".into());
|
||||
c.series[0].values = "Sales!$C$2:$C$7".into();
|
||||
let d = resolve(&wb, 0, &c);
|
||||
assert_eq!(d.categories, months);
|
||||
assert!(d.category_levels.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_trendline_forecast_does_not_collapse_the_columns() {
|
||||
let m = ApproxMeasure;
|
||||
let tc: Rgba = [1, 2, 3, 255];
|
||||
let col: Rgba = [10, 20, 30, 255];
|
||||
let mut s = series("Sales", &[1.0, 3.0, 2.0, 5.0, 4.0], col, ChartKind::ColumnClustered);
|
||||
s.trendlines = vec![TrendlineData { spec: Trendline { forward: 1e6, backward: 1e6, ..Trendline::default() }, color: tc }];
|
||||
let d = ChartData { palette: vec![], category_levels: vec![], categories: ["a", "b", "c", "d", "e"].map(String::from).to_vec(), series: vec![s] };
|
||||
let mut c = chart(ChartKind::ColumnClustered);
|
||||
c.legend = LegendPos::None;
|
||||
let prims = render(&c, &d, 600.0, 400.0, &m);
|
||||
assert_sane(&prims, 600.0, 400.0);
|
||||
let widths: Vec<f32> =
|
||||
prims.iter().filter_map(|p| if let Prim::Rect { w, fill: Some(f), .. } = p { (*f == col).then_some(*w) } else { None }).collect();
|
||||
assert_eq!(widths.len(), 5);
|
||||
assert!(widths.iter().all(|w| *w > 4.0), "columns stay visible: {widths:?}");
|
||||
let trend = lines_of(&prims, tc);
|
||||
assert_eq!(trend.len(), 1);
|
||||
assert!(trend[0].len() >= 20, "the trendline is sampled over the plot, not the forecast: {}", trend[0].len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_whisker_draws_each_series_quartile_method() {
|
||||
use gridcraft_model::QuartileMethod;
|
||||
let mut c = chart(ChartKind::BoxWhisker);
|
||||
c.legend = LegendPos::None;
|
||||
c.series = vec![Series::default(), Series { quartiles: QuartileMethod::Inclusive, ..Default::default() }];
|
||||
let color = [12, 34, 56, 255];
|
||||
let d = ChartData {
|
||||
series: vec![series("A", &[1., 2., 3., 4., 5., 6.], color, c.kind), series("B", &[1., 2., 3., 4., 5., 6.], color, c.kind)],
|
||||
..Default::default()
|
||||
};
|
||||
let prims = render(&c, &d, 640., 400., &ApproxMeasure);
|
||||
let heights: Vec<f32> = prims
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
Prim::Rect { h, fill, .. } if *fill == Some(color) => Some(*h),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(heights.len(), 2);
|
||||
// The boxes span 3.5 and 2.5 data units on the same value axis.
|
||||
assert!((heights[0] / heights[1] - 1.4).abs() < 0.001);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ workspace = true
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
js-sys = "0.3"
|
||||
|
||||
+167
-2
@@ -210,11 +210,18 @@ impl RangeRef {
|
||||
if let (Some(x), Some(y)) = (CellRef::parse(a), CellRef::parse(b)) {
|
||||
return Some(RangeRef::new(x, y));
|
||||
}
|
||||
let strip = |t: &str| t.trim_start_matches('$').to_string();
|
||||
let strip = |t: &str| t.strip_prefix('$').unwrap_or(t).to_string();
|
||||
if let (Some(x), Some(y)) = (letters_to_col(&strip(a)), letters_to_col(&strip(b))) {
|
||||
return Some(RangeRef::cols(x.min(y), x.max(y)));
|
||||
}
|
||||
let (pa, pb) = (strip(a).parse::<u32>().ok()?, strip(b).parse::<u32>().ok()?);
|
||||
let row_num = |t: &str| {
|
||||
let t = t.strip_prefix('$').unwrap_or(t);
|
||||
if t.is_empty() || !t.bytes().all(|c| c.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
t.parse::<u32>().ok()
|
||||
};
|
||||
let (pa, pb) = (row_num(a)?, row_num(b)?);
|
||||
if pa == 0 || pb == 0 || pa > MAX_ROWS || pb > MAX_ROWS {
|
||||
return None;
|
||||
}
|
||||
@@ -230,10 +237,119 @@ impl fmt::Display for RangeRef {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `name` reads as an R1C1 reference: `R`, `C`, `RC`, `R1`, `C1`, `R1C`, `RC1`, `R1C1`
|
||||
/// in any case.
|
||||
fn is_r1c1_token(name: &str) -> bool {
|
||||
let mut chars = name.chars().peekable();
|
||||
let mut letters = 0;
|
||||
for letter in ['r', 'c'] {
|
||||
if chars.peek().is_some_and(|c| c.to_ascii_lowercase() == letter) {
|
||||
chars.next();
|
||||
letters += 1;
|
||||
while chars.peek().is_some_and(|c| c.is_ascii_digit()) {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
letters > 0 && chars.next().is_none()
|
||||
}
|
||||
|
||||
/// Whether Excel quotes a sheet name in a reference (`'My Sheet'!A1`): it starts with a digit or
|
||||
/// `.`, holds anything but letters (any script), digits, `_` and `.`, reads as an A1 cell within
|
||||
/// the sheet or as an R1C1 reference, or is a boolean.
|
||||
pub fn sheet_needs_quotes(name: &str) -> bool {
|
||||
let Some(first) = name.chars().next() else {
|
||||
return true;
|
||||
};
|
||||
if first.is_ascii_digit() || first == '.' || name.chars().any(|c| !(c.is_alphanumeric() || c == '_' || c == '.')) {
|
||||
return true;
|
||||
}
|
||||
CellRef::parse(name).is_some() || is_r1c1_token(name) || name.eq_ignore_ascii_case("TRUE") || name.eq_ignore_ascii_case("FALSE")
|
||||
}
|
||||
|
||||
/// A sheet name as it is written in a reference: quoted, with `'` doubled, when Excel quotes it.
|
||||
pub fn quote_sheet(name: &str) -> String {
|
||||
if sheet_needs_quotes(name) { format!("'{}'", name.replace('\'', "''")) } else { name.to_string() }
|
||||
}
|
||||
|
||||
/// The inverse of [`quote_sheet`]: takes the quotes off a sheet name and reads `''` as `'`.
|
||||
/// Text that isn't quoted is returned as it is.
|
||||
pub fn unquote_sheet(text: &str) -> String {
|
||||
match text.trim().strip_prefix('\'').and_then(|t| t.strip_suffix('\'')) {
|
||||
Some(inner) => inner.replace("''", "'"),
|
||||
None => text.trim().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `'My Sheet'!A1` → (`Some("My Sheet")`, `A1`). Splits at the last `!`, so a quoted name may
|
||||
/// hold one (`'a!b'!A1`).
|
||||
pub fn split_sheet(reference: &str) -> (Option<String>, &str) {
|
||||
match reference.rsplit_once('!') {
|
||||
Some((sheet, body)) => (Some(unquote_sheet(sheet)), body),
|
||||
None => (None, reference),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn range_parse_rejects_signed_rows() {
|
||||
assert!(RangeRef::parse("+1:+3").is_none());
|
||||
assert!(RangeRef::parse("-1:3").is_none());
|
||||
assert!(RangeRef::parse("$$1:3").is_none());
|
||||
assert!(RangeRef::parse(":3").is_none());
|
||||
assert!(RangeRef::parse("$$A:$$C").is_none());
|
||||
assert!(RangeRef::parse("$A:$C").is_some());
|
||||
let r = RangeRef::parse("$1:$3").unwrap();
|
||||
assert_eq!((r.start.row, r.end.row), (0, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sheet_quoting_follows_excel() {
|
||||
for quoted in [
|
||||
"A1",
|
||||
"XFD1",
|
||||
"AB1048576",
|
||||
"R1",
|
||||
"C1",
|
||||
"RC1",
|
||||
"R",
|
||||
"C",
|
||||
"RC",
|
||||
"R1C",
|
||||
"R1C1",
|
||||
"r2c3",
|
||||
"CR1",
|
||||
".x",
|
||||
"TRUE",
|
||||
"false",
|
||||
"1abc",
|
||||
"My Sheet",
|
||||
"Bob's",
|
||||
"a-b",
|
||||
"",
|
||||
] {
|
||||
assert!(sheet_needs_quotes(quoted), "{quoted:?} should be quoted");
|
||||
}
|
||||
for bare in ["Sheet1", "XFE1", "AB1048577", "Ärger", "日本", "Σ", "Sheet.1", "_x", "A1A", "Data", "RR", "CRX"] {
|
||||
assert!(!sheet_needs_quotes(bare), "{bare:?} should stay bare");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quote_unquote_and_split_sheet() {
|
||||
assert_eq!(quote_sheet("Bob's"), "'Bob''s'");
|
||||
assert_eq!(quote_sheet("Data"), "Data");
|
||||
assert_eq!(unquote_sheet("'Bob''s'"), "Bob's");
|
||||
assert_eq!(unquote_sheet("Data"), "Data");
|
||||
assert_eq!(unquote_sheet("'"), "'");
|
||||
assert_eq!(split_sheet("'a!b'!A1"), (Some("a!b".to_string()), "A1"));
|
||||
assert_eq!(split_sheet("'Bob''s'!$A$1:$B$2"), (Some("Bob's".to_string()), "$A$1:$B$2"));
|
||||
assert_eq!(split_sheet("A1"), (None, "A1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn letters_roundtrip() {
|
||||
for (c, s) in [(0, "A"), (25, "Z"), (26, "AA"), (51, "AZ"), (52, "BA"), (701, "ZZ"), (702, "AAA"), (16383, "XFD")] {
|
||||
@@ -293,3 +409,52 @@ mod tests {
|
||||
assert_eq!(CellRef::new(0, 0).offset_clamped(-5, 3), CellRef::new(0, 3));
|
||||
}
|
||||
}
|
||||
|
||||
/// The shape of a chart's category range beside its values: a plain list, or a multi-level
|
||||
/// axis (Excel: a category range two or more columns wide beside a column of values is a
|
||||
/// two-level axis, year beside month; one two or more rows tall above a row of values likewise).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct CategoryLevels {
|
||||
/// The levels are columns and the points run down the rows (else the levels are rows and the
|
||||
/// points run along the columns).
|
||||
pub by_columns: bool,
|
||||
/// Outer levels beyond the innermost labels (the column or row nearest the values): 1 for a
|
||||
/// two-level axis. At most 3 are kept.
|
||||
pub outer: u32,
|
||||
}
|
||||
|
||||
/// How `categories` lays its labels out beside `values` (`None` when the values aren't a
|
||||
/// reference: the categories' own shape decides). `None` for a plain list of labels.
|
||||
pub fn category_levels(categories: &RangeRef, values: Option<&RangeRef>) -> Option<CategoryLevels> {
|
||||
if categories.is_full_rows() || categories.is_full_cols() {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = (categories.width(), categories.height());
|
||||
let vertical = match values {
|
||||
Some(v) => v.height() >= v.width(),
|
||||
None => h >= w,
|
||||
};
|
||||
let outer = if vertical { w.saturating_sub(1) } else { h.saturating_sub(1) };
|
||||
(outer > 0).then_some(CategoryLevels { by_columns: vertical, outer: outer.min(3) })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod category_level_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_wide_range_beside_a_column_of_values_is_a_multi_level_axis() {
|
||||
let r = |s: &str| RangeRef::parse(s).unwrap();
|
||||
assert_eq!(category_levels(&r("A4:B39"), Some(&r("C4:C39"))), Some(CategoryLevels { by_columns: true, outer: 1 }));
|
||||
assert_eq!(category_levels(&r("A4:D39"), Some(&r("C4:C39"))), Some(CategoryLevels { by_columns: true, outer: 3 }));
|
||||
assert_eq!(category_levels(&r("A4:F39"), Some(&r("C4:C39"))).map(|l| l.outer), Some(3), "capped");
|
||||
assert_eq!(category_levels(&r("A4:A39"), Some(&r("C4:C39"))), None, "one column: a plain list");
|
||||
// A row of values: levels are rows.
|
||||
assert_eq!(category_levels(&r("B2:M3"), Some(&r("B4:M4"))), Some(CategoryLevels { by_columns: false, outer: 1 }));
|
||||
assert_eq!(category_levels(&r("B3:M3"), Some(&r("B4:M4"))), None);
|
||||
// Literal values: the categories' own shape decides.
|
||||
assert_eq!(category_levels(&r("A1:B6"), None), Some(CategoryLevels { by_columns: true, outer: 1 }));
|
||||
assert_eq!(category_levels(&r("A1:F2"), None), Some(CategoryLevels { by_columns: false, outer: 1 }));
|
||||
assert_eq!(category_levels(&r("A:B"), Some(&r("C:C"))), None, "whole columns");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
//! Shared legacy indexed colours used by XLSX and number-format rendering.
|
||||
|
||||
/// The 64-entry legacy indexed colour palette, stored as `RRGGBB`.
|
||||
pub const INDEXED: [u32; 64] = [
|
||||
0x000000, 0xFFFFFF, 0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00, 0xFF00FF, 0x00FFFF, 0x000000, 0xFFFFFF, 0xFF0000, 0x00FF00, 0x0000FF, 0xFFFF00,
|
||||
0xFF00FF, 0x00FFFF, 0x800000, 0x008000, 0x000080, 0x808000, 0x800080, 0x008080, 0xC0C0C0, 0x808080, 0x9999FF, 0x993366, 0xFFFFCC, 0xCCFFFF,
|
||||
0x660066, 0xFF8080, 0x0066CC, 0xCCCCFF, 0x000080, 0xFF00FF, 0xFFFF00, 0x00FFFF, 0x800080, 0x800000, 0x008080, 0x0000FF, 0x00CCFF, 0xCCFFFF,
|
||||
0xCCFFCC, 0xFFFF99, 0x99CCFF, 0xFF99CC, 0xCC99FF, 0xFFCC99, 0x3366FF, 0x33CCCC, 0x99CC00, 0xFFCC00, 0xFF9900, 0xFF6600, 0x666699, 0x969696,
|
||||
0x003366, 0x339966, 0x003300, 0x333300, 0x993300, 0x993366, 0x333399, 0x333333,
|
||||
];
|
||||
|
||||
/// Resolve an Excel number-format color index (`Color1` through `Color56`).
|
||||
pub fn indexed_color(index: u8) -> Option<[u8; 3]> {
|
||||
if !(1..=56).contains(&index) {
|
||||
return None;
|
||||
}
|
||||
let slot = index.checked_add(7)?;
|
||||
let rgb = *INDEXED.get(slot as usize)?;
|
||||
Some([(rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8])
|
||||
}
|
||||
+111
-24
@@ -28,15 +28,22 @@ pub struct DateTime {
|
||||
pub weekday: u32,
|
||||
}
|
||||
|
||||
/// Days from 0000-03-01 style civil algorithm (Howard Hinnant), days since 1970-01-01.
|
||||
/// Days since 1970-01-01 for a proleptic Gregorian date, exact for every `i32` year.
|
||||
///
|
||||
/// `NaiveDate` only covers about ±262143 years, but the Gregorian calendar repeats every 400
|
||||
/// years (146_097 days): chrono converts the year within its cycle and whole cycles are added
|
||||
/// back as days (an i128 so the i32 extremes can't overflow). 1970-01-01 is `num_days_from_ce`
|
||||
/// 719_163; the result matches the proleptic Gregorian count the old hand-rolled code gave.
|
||||
pub fn days_from_civil(y: i32, m: u32, d: u32) -> i64 {
|
||||
let y = if m <= 2 { y as i64 - 1 } else { y as i64 };
|
||||
let era = if y >= 0 { y } else { y - 399 } / 400;
|
||||
let yoe = y - era * 400;
|
||||
let m = m as i64;
|
||||
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d as i64 - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
era * 146097 + doe - 719468
|
||||
let (cycles, year) = (i128::from(i64::from(y).div_euclid(400)), i64::from(y).rem_euclid(400));
|
||||
i32::try_from(year)
|
||||
.ok()
|
||||
.and_then(|year| chrono::NaiveDate::from_ymd_opt(year, m, d))
|
||||
.and_then(|date| {
|
||||
let days = i128::from(chrono::Datelike::num_days_from_ce(&date)) - 719_163 + cycles * 146_097;
|
||||
i64::try_from(days).ok()
|
||||
})
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Milliseconds since the Unix epoch (UTC) from the system clock; `None` when the clock is unavailable.
|
||||
@@ -67,32 +74,50 @@ pub fn serial_in_system(sys: DateSystem, serial_1900: f64) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Proleptic Gregorian `(year, month, day)` for a day count `z` since 1970-01-01 (the inverse of
|
||||
/// [`days_from_civil`]).
|
||||
///
|
||||
/// The calendar repeats every 400 years (146_097 days): chrono converts the day within its cycle
|
||||
/// and whole cycles go back into the year (an i128 so no `z` overflows), which matches the
|
||||
/// proleptic Gregorian answer the old hand-rolled code gave past `NaiveDate`'s ±262143 range.
|
||||
/// 1970-01-01 is `num_days_from_ce` 719_163.
|
||||
pub fn civil_from_days(z: i64) -> (i32, u32, u32) {
|
||||
let z = z + 719468;
|
||||
let era = if z >= 0 { z } else { z - 146096 } / 146097;
|
||||
let doe = z - era * 146097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||
((if m <= 2 { y + 1 } else { y }) as i32, m, d)
|
||||
use chrono::Datelike;
|
||||
let (cycles, rest) = (z.div_euclid(146_097), z.rem_euclid(146_097));
|
||||
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
|
||||
Some(d) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i32::try_from(y).unwrap_or(if y < 0 { i32::MIN } else { i32::MAX }), d.month(), d.day())
|
||||
}
|
||||
// Unreachable: `rest + 719_163` is always a valid CE day.
|
||||
None => (1970, 1, 1),
|
||||
}
|
||||
}
|
||||
|
||||
const UNIX_1899_12_30: i64 = -25569; // days_from_civil(1899, 12, 30)
|
||||
const UNIX_1904_01_01: i64 = -24107;
|
||||
|
||||
/// Whether `y` is a Gregorian leap year, for every `i32` year.
|
||||
pub fn is_leap(y: i32) -> bool {
|
||||
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
|
||||
// A year is a leap year iff 29 February exists in it. Leap years repeat every 400 years, so
|
||||
// the year within its Gregorian cycle (`rem_euclid(400)`) decides it and always fits chrono.
|
||||
let y = i64::from(y).rem_euclid(400) as i32;
|
||||
chrono::NaiveDate::from_ymd_opt(y, 2, 29).is_some()
|
||||
}
|
||||
|
||||
/// Number of days in month `m` of year `y` (1–31), or `0` for an invalid month number, for every
|
||||
/// `i32` year.
|
||||
pub fn days_in_month(y: i32, m: u32) -> u32 {
|
||||
match m {
|
||||
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
||||
4 | 6 | 9 | 11 => 30,
|
||||
2 if is_leap(y) => 29,
|
||||
2 => 28,
|
||||
// The length of a month is the day before the first of the next month. Month lengths repeat
|
||||
// every 400 years, so the year within its Gregorian cycle (`rem_euclid(400)`) is enough and
|
||||
// always fits chrono (so December can't overflow `y + 1`). An invalid month keeps the old `0`.
|
||||
if !(1..=12).contains(&m) {
|
||||
return 0;
|
||||
}
|
||||
let y = i64::from(y).rem_euclid(400) as i32;
|
||||
let first = |y: i32, m: u32| chrono::NaiveDate::from_ymd_opt(y, m, 1);
|
||||
match (first(y, m), if m == 12 { first(y + 1, 1) } else { first(y, m + 1) }) {
|
||||
(Some(a), Some(b)) => u32::try_from((b - a).num_days()).unwrap_or(0),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
@@ -196,6 +221,68 @@ pub const WEEKDAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Th
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn leap_and_month_length_outside_chrono_range() {
|
||||
// In range, the ordinary Gregorian rule holds.
|
||||
assert!(is_leap(2000));
|
||||
assert!(!is_leap(1900));
|
||||
assert!(is_leap(2024));
|
||||
assert!(!is_leap(2023));
|
||||
// Outside chrono's NaiveDate range the Gregorian rule is still applied.
|
||||
assert!(is_leap(400_000)); // divisible by 400
|
||||
assert!(!is_leap(300_100)); // century not divisible by 400
|
||||
assert!(is_leap(i32::MAX - 3)); // 2147483644 is divisible by 4, not a century
|
||||
assert!(!is_leap(i32::MAX)); // 2147483647 is odd
|
||||
// days_in_month keeps calendar lengths out of range and 0 for invalid months.
|
||||
assert_eq!(days_in_month(400_000, 2), 29);
|
||||
assert_eq!(days_in_month(300_100, 2), 28);
|
||||
assert_eq!(days_in_month(400_000, 1), 31);
|
||||
assert_eq!(days_in_month(400_000, 4), 30);
|
||||
assert_eq!(days_in_month(2024, 2), 29);
|
||||
assert_eq!(days_in_month(2023, 2), 28);
|
||||
assert_eq!(days_in_month(2023, 0), 0);
|
||||
assert_eq!(days_in_month(2023, 13), 0);
|
||||
// December at i32::MAX does not overflow and still reports 31 days.
|
||||
assert_eq!(days_in_month(i32::MAX, 12), 31);
|
||||
assert_eq!(days_in_month(i32::MAX, 2), 28); // 2147483647 is not a leap year
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn civil_conversions_exact_beyond_chrono_range() {
|
||||
assert_eq!(days_from_civil(1970, 1, 1), 0);
|
||||
assert_eq!(civil_from_days(0), (1970, 1, 1));
|
||||
// Past `NaiveDate`'s ±262143 the 400-year cycles carry the result, exactly, both sides of
|
||||
// the epoch; values round-trip through the proleptic Gregorian converters.
|
||||
for (y, m, d, z) in [
|
||||
(262_144i32, 1u32, 1u32, 95_026_602i64),
|
||||
(300_000, 1, 1, 108_853_222),
|
||||
(400_000, 2, 29, 145_377_531),
|
||||
(-262_145, 3, 1, -96_465_964),
|
||||
(-300_000, 6, 15, -110_292_112),
|
||||
] {
|
||||
assert_eq!(days_from_civil(y, m, d), z, "{y}-{m}-{d}");
|
||||
assert_eq!(civil_from_days(z), (y, m, d), "{z}");
|
||||
}
|
||||
// The extremes must not overflow or panic (i128 intermediates).
|
||||
let _ = (days_from_civil(i32::MAX, 12, 31), days_from_civil(i32::MIN, 1, 1), civil_from_days(i64::MAX), civil_from_days(i64::MIN));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serials_unchanged_through_cycle_conversion() {
|
||||
// The 1900 leap bug and 1904 semantics must stay exact; the converters only ever see
|
||||
// in-range years here, so the serial outputs are byte-for-byte the pre-chrono ones.
|
||||
let s = DateSystem::D1900;
|
||||
assert_eq!(serial_from_ymd(s, 1900, 2, 29), Some(60.0)); // Excel's fictitious leap day
|
||||
assert_eq!(serial_from_ymd(s, 1900, 3, 1), Some(61.0));
|
||||
assert_eq!(serial_from_ymd(s, 9999, 12, 31), Some(2958465.0));
|
||||
assert_eq!(days_in_month_in(s, 1900, 2), 29);
|
||||
let s4 = DateSystem::D1904;
|
||||
assert_eq!(serial_from_ymd(s4, 1904, 1, 1), Some(0.0)); // serial 0 is 1904-01-01
|
||||
assert_eq!(serial_from_ymd(s4, 1904, 2, 29), Some(59.0)); // 1904 is a real leap year
|
||||
assert_eq!(serial_from_ymd(s4, 1900, 2, 29), None); // no fictitious day in 1904 system
|
||||
assert_eq!(days_in_month_in(s4, 1900, 2), 28);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serial_from_unix_time() {
|
||||
assert_eq!(serial_from_unix_ms(0.0), 25569.0);
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod addr;
|
||||
pub mod color;
|
||||
pub mod date;
|
||||
pub mod parse;
|
||||
pub mod value;
|
||||
|
||||
pub use addr::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, col_to_letters, letters_to_col};
|
||||
pub use addr::{
|
||||
CategoryLevels, CellRef, MAX_COLS, MAX_ROWS, RangeRef, category_levels, col_to_letters, letters_to_col, quote_sheet, sheet_needs_quotes,
|
||||
split_sheet, unquote_sheet,
|
||||
};
|
||||
pub use date::DateSystem;
|
||||
pub use value::{Array, CellError, Value, compare, compare_numbers, compare_text, number_to_text, sort_compare};
|
||||
|
||||
+111
-12
@@ -49,14 +49,34 @@ pub fn parse_number_text_in(s: &str, sys: DateSystem) -> Option<f64> {
|
||||
parse_numeric(s.trim(), sys).map(|(n, _)| n)
|
||||
}
|
||||
|
||||
/// Text that Excel's "Number Stored as Text" check flags: it would be a number if typed (signs,
|
||||
/// thousands separators, currency, percent, exponent, a parenthesised negative). Dates, times and
|
||||
/// fractions kept as text are not flagged, as in Excel.
|
||||
pub fn number_stored_as_text(s: &str) -> Option<f64> {
|
||||
let t = s.trim();
|
||||
match t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) {
|
||||
Some(inner) => parse_plain(inner.trim()).map(|(n, _)| -n),
|
||||
None => parse_plain(t).map(|(n, _)| n),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_numeric(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> {
|
||||
// Parenthesised negatives: (100) = -100. Each level negates, so ((100)) = 100. Stripped in a
|
||||
// loop, not by recursion: runtime text (e.g. from REPT) can nest thousands deep (#2180).
|
||||
let mut t = t;
|
||||
let mut neg = false;
|
||||
while let Some(inner) = t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) {
|
||||
t = inner.trim();
|
||||
neg = !neg;
|
||||
}
|
||||
let (n, f) = parse_unparenthesised(t, sys)?;
|
||||
Some((if neg { -n } else { n }, f))
|
||||
}
|
||||
|
||||
fn parse_unparenthesised(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> {
|
||||
if t.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Parenthesised negatives: (100) = -100.
|
||||
if let Some(inner) = t.strip_prefix('(').and_then(|x| x.strip_suffix(')')) {
|
||||
return parse_numeric(inner.trim(), sys).map(|(n, f)| (-n, f));
|
||||
}
|
||||
if let Some(r) = parse_plain(t) {
|
||||
return Some(r);
|
||||
}
|
||||
@@ -146,18 +166,36 @@ fn parse_plain(t: &str) -> Option<(f64, Option<&'static str>)> {
|
||||
Some((n, fmt))
|
||||
}
|
||||
|
||||
/// A plain ASCII number: digits, with an optional `.digits` fraction when `frac` is set. Rejects
|
||||
/// `nan`, `inf`, signs and exponents that `f64::from_str` would accept, and digits too many for a
|
||||
/// finite number (#2163).
|
||||
fn plain_number(s: &str, frac: bool) -> Option<f64> {
|
||||
let (int, dec) = match s.split_once('.') {
|
||||
Some((i, d)) if frac => (i, Some(d)),
|
||||
_ => (s, None),
|
||||
};
|
||||
let digits = |x: &str| !x.is_empty() && x.bytes().all(|c| c.is_ascii_digit());
|
||||
if !digits(int) || dec.is_some_and(|d| !digits(d)) {
|
||||
return None;
|
||||
}
|
||||
s.parse().ok().filter(|x: &f64| x.is_finite())
|
||||
}
|
||||
|
||||
/// `1/2` (only with a whole part, else it's a date), `1 1/2`, `0 3/4`.
|
||||
fn parse_fraction(t: &str) -> Option<f64> {
|
||||
let (whole, frac) = t.split_once(' ')?;
|
||||
let neg = whole.starts_with('-');
|
||||
let w: f64 = whole.trim_start_matches('-').parse().ok()?;
|
||||
let w = plain_number(whole.trim_start_matches('-'), false)?;
|
||||
let (a, b) = frac.trim().split_once('/')?;
|
||||
let a: f64 = a.parse().ok()?;
|
||||
let b: f64 = b.parse().ok()?;
|
||||
let a = plain_number(a, false)?;
|
||||
let b = plain_number(b, false)?;
|
||||
if b == 0.0 || !w.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let v = w + a / b;
|
||||
if !v.is_finite() {
|
||||
return None;
|
||||
}
|
||||
Some(if neg { -v } else { v })
|
||||
}
|
||||
|
||||
@@ -184,12 +222,12 @@ fn parse_time(t: &str) -> Option<(f64, bool, bool)> {
|
||||
if parts.len() == 1 && ampm.is_none() {
|
||||
return None;
|
||||
}
|
||||
if parts.len() > 3 || parts.iter().any(|p| p.is_empty()) {
|
||||
if parts.len() > 3 || parts.iter().any(|p| p.is_empty() || p.starts_with('+')) {
|
||||
return None;
|
||||
}
|
||||
let mut h: f64 = parts.first()?.parse().ok()?;
|
||||
let m: f64 = parts.get(1).map_or(Some(0.0), |p| p.parse().ok())?;
|
||||
let s: f64 = parts.get(2).map_or(Some(0.0), |p| p.parse().ok())?;
|
||||
let mut h = plain_number(parts.first()?, false)?;
|
||||
let m = parts.get(1).map_or(Some(0.0), |p| plain_number(p, false))?;
|
||||
let s = parts.get(2).map_or(Some(0.0), |p| plain_number(p, true))?;
|
||||
if m >= 60.0 || s >= 60.0 || h < 0.0 || m < 0.0 || s < 0.0 {
|
||||
return None;
|
||||
}
|
||||
@@ -211,7 +249,13 @@ fn parse_time(t: &str) -> Option<(f64, bool, bool)> {
|
||||
|
||||
fn parse_date(t: &str, sys: DateSystem) -> Option<(f64, &'static str)> {
|
||||
let t = t.trim().trim_end_matches(',');
|
||||
let num = |s: &str| s.trim().parse::<i64>().ok();
|
||||
let num = |s: &str| {
|
||||
let s = s.trim();
|
||||
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
s.parse::<i64>().ok()
|
||||
};
|
||||
// ISO yyyy-mm-dd or yyyy/mm/dd
|
||||
let seps: &[char] = &['/', '-', '.'];
|
||||
let parts: Vec<&str> = t.split(seps).collect();
|
||||
@@ -326,6 +370,32 @@ mod tests {
|
||||
parse_input(s, DateSystem::D1900)
|
||||
}
|
||||
|
||||
/// #2163: digits too many for a finite number don't make one (∞/∞ was NaN).
|
||||
#[test]
|
||||
fn overlong_digits_stay_text() {
|
||||
let big = "9".repeat(400);
|
||||
for s in [format!("1 {big}/{big}"), format!("1 {big}/2"), format!("1 1/{big}0"), format!("{big}:00"), format!("1:00:{big}.5")] {
|
||||
assert_eq!(p(&s).value, Value::text(s.as_str()), "{s}");
|
||||
assert_eq!(parse_number_text(&s), None, "{s}");
|
||||
}
|
||||
assert_eq!(parse_number_text("1 1/2"), Some(1.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_digit_time_and_fraction_parts_stay_text() {
|
||||
for s in ["1:nan", "nan:30", "1:30:NaN", "1 nan/2", "1 1/nan", "inf:00", "1e1:00", "+5:00"] {
|
||||
assert_eq!(p(s).value, Value::text(s), "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_date_parts_stay_text() {
|
||||
for s in ["+3/4/2026", "3/+4/2026", "3/4/+2026", "1/1/2020 +5:00"] {
|
||||
assert_eq!(p(s).value, Value::text(s), "{s}");
|
||||
}
|
||||
assert_eq!(p("3/4/2026").value, Value::Number(46085.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn month_name_short_year() {
|
||||
for s in ["Mar 4, 26", "Mar 4 26", "March 4 26", "4 March 26", "Mar 4, 2026"] {
|
||||
@@ -352,6 +422,24 @@ mod tests {
|
||||
assert_eq!(p(".5").value, Value::Number(0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deeply_nested_parentheses_do_not_overflow() {
|
||||
// #2180: coercion used to recurse once per parenthesis and overflow the stack.
|
||||
let depth = 100_000;
|
||||
let s = format!("{}1{}", "(".repeat(depth), ")".repeat(depth));
|
||||
assert_eq!(parse_number_text(&s), Some(1.0));
|
||||
let odd = format!("{}1{}", "(".repeat(depth + 1), ")".repeat(depth + 1));
|
||||
assert_eq!(parse_number_text(&odd), Some(-1.0));
|
||||
let unbalanced = format!("{}1{}", "(".repeat(depth), ")".repeat(depth - 1));
|
||||
assert_eq!(parse_number_text(&unbalanced), None);
|
||||
assert_eq!(p(&s).value, Value::Number(1.0));
|
||||
// Existing results are unchanged.
|
||||
assert_eq!(parse_number_text("(100)"), Some(-100.0));
|
||||
assert_eq!(parse_number_text("((100))"), Some(100.0));
|
||||
assert_eq!(parse_number_text("( $1,200.50 )"), Some(-1200.5));
|
||||
assert_eq!(parse_number_text("()"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texts_and_specials() {
|
||||
assert_eq!(p("hello").value, Value::text("hello"));
|
||||
@@ -398,4 +486,15 @@ mod tests {
|
||||
assert_eq!(parse_input("2/29/1900", DateSystem::D1904).value, Value::text("2/29/1900"));
|
||||
assert_eq!(parse_input("2/29/1904", DateSystem::D1904).value, Value::Number(59.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_stored_as_text() {
|
||||
for (text, n) in [("123", 123.0), (" 42 ", 42.0), ("-1,000.5", -1000.5), ("$3", 3.0), ("50%", 0.5), ("1E3", 1000.0), ("(100)", -100.0)] {
|
||||
assert_eq!(number_stored_as_text(text), Some(n), "{text}");
|
||||
}
|
||||
// Dates, times, fractions, words and blanks are not flagged.
|
||||
for text in ["3/4/2026", "10:30", "1 1/2", "abc", "12abc", "", " ", "TRUE", "#N/A", "((1))"] {
|
||||
assert_eq!(number_stored_as_text(text), None, "{text}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,6 +359,10 @@ pub fn number_to_text(n: f64) -> String {
|
||||
if !(1e-9..1e15).contains(&a) && a.round() != a || a >= 1e15 {
|
||||
return sci_text(n, 15);
|
||||
}
|
||||
// A value just below 1e15 can round up to 1e15 at 15 significant digits.
|
||||
if format!("{:.14e}", a).parse::<f64>().is_ok_and(|r| r >= 1e15) {
|
||||
return sci_text(n, 15);
|
||||
}
|
||||
// 15 significant digits, trailing zeros trimmed.
|
||||
let mag = a.log10().floor() as i32;
|
||||
let decimals = (14 - mag).clamp(0, 30) as usize;
|
||||
@@ -402,6 +406,13 @@ mod tests {
|
||||
assert_eq!(number_to_text(1.0 / 3.0), "0.333333333333333");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_text_rounds_up_to_scientific_boundary() {
|
||||
assert_eq!(number_to_text(999999999999999.9), "1E+15");
|
||||
assert_eq!(number_to_text(-999999999999999.9), "-1E+15");
|
||||
assert_eq!(number_to_text(999999999999999.0), "999999999999999");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coercion() {
|
||||
assert_eq!(Value::from("12").to_number(), Ok(12.0));
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Times the fill-handle double-click scan against a densely-filled neighbour column: the old
|
||||
//! row-by-row `value_ref` loop from `grid.rs` vs. `Sheet::last_contiguous_nonempty`.
|
||||
//! `cargo run --release -p gridcraft-engine --example fill_handle_perf -- [rows]`
|
||||
|
||||
#![allow(clippy::disallowed_methods)] // a native-only benchmark: wasm never runs it
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::core::CellRef;
|
||||
use serde_json::json;
|
||||
|
||||
fn main() {
|
||||
let rows: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(1_000_000);
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let t = Instant::now();
|
||||
let chunk = 10_000u32;
|
||||
let mut r = 0u32;
|
||||
while r < rows {
|
||||
let n = chunk.min(rows - r);
|
||||
let vals: Vec<serde_json::Value> = (0..n).map(|i| json!([(r + i) as f64])).collect();
|
||||
s.execute("range.setValues", json!({"range": format!("A{}", r + 1), "values": vals})).expect("set");
|
||||
r += n;
|
||||
}
|
||||
println!("fill A1:A{rows}: {:?}", t.elapsed());
|
||||
|
||||
let d = s.doc().expect("doc");
|
||||
let sh = d.wb.active().expect("sheet");
|
||||
let neighbour = 0u32; // column A
|
||||
let cap = (rows - 1).min(999_999);
|
||||
|
||||
let t = Instant::now();
|
||||
let mut last = 0u32;
|
||||
let mut iters = 0u64;
|
||||
while last + 1 < gridcraft_engine::core::MAX_ROWS && sh.value_ref(CellRef::new(last + 1, neighbour)).is_some_and(|v| !v.is_empty()) && last < cap
|
||||
{
|
||||
last += 1;
|
||||
iters += 1;
|
||||
}
|
||||
let old = t.elapsed();
|
||||
println!("old row-by-row scan: {iters} iterations, {old:?} ({:.1} ns/iter)", old.as_nanos() as f64 / iters.max(1) as f64);
|
||||
|
||||
let t = Instant::now();
|
||||
let new_last = sh.last_contiguous_nonempty(neighbour, 1, cap);
|
||||
let new_t = t.elapsed();
|
||||
println!("new last_contiguous_nonempty: last={new_last}, {new_t:?} ({:.2}x faster)", old.as_secs_f64() / new_t.as_secs_f64().max(1e-12));
|
||||
|
||||
assert_eq!(last, new_last, "old and new scans must agree");
|
||||
}
|
||||
@@ -273,6 +273,7 @@ pub const CATALOG: &[(&str, &str, &str, &str)] = &[
|
||||
("automate.newScript", "Automate", "Scripting Tools", "New Script"),
|
||||
("automate.allScripts", "Automate", "Scripting Tools", "All Scripts"),
|
||||
("automate.recordActions", "Automate", "Scripting Tools", "Record Actions"),
|
||||
("automate.trustScripts", "Automate", "Scripting Tools", "Trust Scripts"),
|
||||
// Table Design
|
||||
("table.rename", "Table Design", "Properties", "Table Name"),
|
||||
("table.resize", "Table Design", "Properties", "Resize Table"),
|
||||
|
||||
+194
-25
@@ -34,7 +34,43 @@ struct RuleStats {
|
||||
counts: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
fn numbers_in(sh: &Sheet, ranges: &[RangeRef]) -> (Vec<f64>, HashMap<String, usize>) {
|
||||
/// Which statistics a rule reads. Scanning the rule's whole range for them costs O(range), so
|
||||
/// rules that read none (cell value, formula, text, blanks, errors, dates) never scan.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum StatsNeed {
|
||||
/// Sorted numbers, their mean and deviation (top/bottom N, above average, scales, bars, icons).
|
||||
Numbers,
|
||||
/// How often each displayed text occurs (duplicate / unique values).
|
||||
Counts,
|
||||
}
|
||||
|
||||
fn stats_need(rule: &CfRule) -> Option<StatsNeed> {
|
||||
match rule {
|
||||
CfRule::Duplicate { .. } | CfRule::Unique { .. } => Some(StatsNeed::Counts),
|
||||
CfRule::Top10 { .. } | CfRule::AboveAverage { .. } | CfRule::ColorScale { .. } | CfRule::DataBar { .. } | CfRule::IconSet { .. } => {
|
||||
Some(StatsNeed::Numbers)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
impl RuleStats {
|
||||
fn build(sh: &Sheet, ranges: &[RangeRef], need: StatsNeed) -> RuleStats {
|
||||
let (sorted, counts) = numbers_in(sh, ranges, need);
|
||||
let n = sorted.len().max(1) as f64;
|
||||
let avg = sorted.iter().sum::<f64>() / n;
|
||||
let std = (sorted.iter().map(|x| (x - avg).powi(2)).sum::<f64>() / n).sqrt();
|
||||
RuleStats { sorted, avg, std, counts }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Cells `numbers_in` visited on this thread (tests count the work a lookup does).
|
||||
static SCANNED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
fn numbers_in(sh: &Sheet, ranges: &[RangeRef], need: StatsNeed) -> (Vec<f64>, HashMap<String, usize>) {
|
||||
let mut nums = Vec::new();
|
||||
let mut counts = HashMap::new();
|
||||
for r in ranges {
|
||||
@@ -43,12 +79,20 @@ fn numbers_in(sh: &Sheet, ranges: &[RangeRef]) -> (Vec<f64>, HashMap<String, usi
|
||||
None => continue,
|
||||
};
|
||||
for c in r.iter().take(2_000_000) {
|
||||
#[cfg(test)]
|
||||
SCANNED.with(|n| n.set(n.get() + 1));
|
||||
let v = sh.value(c);
|
||||
if let Value::Number(n) = v {
|
||||
nums.push(n);
|
||||
}
|
||||
if !v.is_empty() {
|
||||
*counts.entry(v.display().to_lowercase()).or_insert(0) += 1;
|
||||
match need {
|
||||
StatsNeed::Numbers => {
|
||||
if let Value::Number(n) = v {
|
||||
nums.push(n);
|
||||
}
|
||||
}
|
||||
StatsNeed::Counts => {
|
||||
if !v.is_empty() {
|
||||
*counts.entry(v.display().to_lowercase()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,19 +153,13 @@ impl CfCache {
|
||||
sh.cond_formats.iter().enumerate().filter(|(_, cf)| cf.ranges.iter().any(|r| r.contains(c))).collect();
|
||||
rules.sort_by_key(|(_, cf)| cf.priority);
|
||||
let v = sh.value(c);
|
||||
let no_stats = RuleStats::default();
|
||||
for (idx, cf) in rules {
|
||||
let first = cf.ranges.first().map(|r| r.start).unwrap_or(c);
|
||||
let st = self
|
||||
.stats
|
||||
.entry(idx)
|
||||
.or_insert_with(|| {
|
||||
let (sorted, counts) = numbers_in(sh, &cf.ranges);
|
||||
let n = sorted.len().max(1) as f64;
|
||||
let avg = sorted.iter().sum::<f64>() / n;
|
||||
let std = (sorted.iter().map(|x| (x - avg).powi(2)).sum::<f64>() / n).sqrt();
|
||||
RuleStats { sorted, avg, std, counts }
|
||||
})
|
||||
.clone();
|
||||
let st: &RuleStats = match stats_need(&cf.rule) {
|
||||
Some(need) => &*self.stats.entry(idx).or_insert_with(|| RuleStats::build(sh, &cf.ranges, need)),
|
||||
None => &no_stats,
|
||||
};
|
||||
let text = v.display();
|
||||
let matched_style: Option<&Style> = match &cf.rule {
|
||||
CfRule::CellIs { op, a, b, style } => {
|
||||
@@ -147,10 +185,18 @@ impl CfCache {
|
||||
CfRule::Expression { formula, style } => {
|
||||
gridcraft_calc::evaluate(wb, sheet, c, &relative(formula, first, c)).to_bool().unwrap_or(false).then_some(style.as_ref())
|
||||
}
|
||||
CfRule::ContainsText { text: t, style } => text.to_lowercase().contains(&t.to_lowercase()).then_some(style.as_ref()),
|
||||
CfRule::NotContainsText { text: t, style } => (!text.to_lowercase().contains(&t.to_lowercase())).then_some(style.as_ref()),
|
||||
CfRule::BeginsWith { text: t, style } => text.to_lowercase().starts_with(&t.to_lowercase()).then_some(style.as_ref()),
|
||||
CfRule::EndsWith { text: t, style } => text.to_lowercase().ends_with(&t.to_lowercase()).then_some(style.as_ref()),
|
||||
// As in Excel, an error value never contains, begins or ends with anything (its rule
|
||||
// formulas SEARCH/LEFT/RIGHT fail on it), so it always "does not contain" (#1162).
|
||||
CfRule::ContainsText { text: t, style } => {
|
||||
(!v.is_error() && text.to_lowercase().contains(&t.to_lowercase())).then_some(style.as_ref())
|
||||
}
|
||||
CfRule::NotContainsText { text: t, style } => {
|
||||
(v.is_error() || !text.to_lowercase().contains(&t.to_lowercase())).then_some(style.as_ref())
|
||||
}
|
||||
CfRule::BeginsWith { text: t, style } => {
|
||||
(!v.is_error() && text.to_lowercase().starts_with(&t.to_lowercase())).then_some(style.as_ref())
|
||||
}
|
||||
CfRule::EndsWith { text: t, style } => (!v.is_error() && text.to_lowercase().ends_with(&t.to_lowercase())).then_some(style.as_ref()),
|
||||
CfRule::Blanks { style } => (v.is_empty() || text.trim().is_empty()).then_some(style.as_ref()),
|
||||
CfRule::NoBlanks { style } => (!v.is_empty() && !text.trim().is_empty()).then_some(style.as_ref()),
|
||||
CfRule::Errors { style } => v.is_error().then_some(style.as_ref()),
|
||||
@@ -218,7 +264,7 @@ impl CfCache {
|
||||
if let Some(n) = v.as_f64() {
|
||||
let pts: Vec<(f64, [u8; 3])> = stops
|
||||
.iter()
|
||||
.map(|(k, col)| (threshold(wb, sheet, c, &st, k), col.resolve(&wb.theme).unwrap_or([255, 255, 255])))
|
||||
.map(|(k, col)| (threshold(wb, sheet, c, st, k), col.resolve(&wb.theme).unwrap_or([255, 255, 255])))
|
||||
.collect();
|
||||
let rgb = match pts.as_slice() {
|
||||
[(a, ca), (b, cb)] => lerp(*ca, *cb, if b > a { (n - a) / (b - a) } else { 0.0 }),
|
||||
@@ -240,8 +286,8 @@ impl CfCache {
|
||||
}
|
||||
CfRule::DataBar { min, max, color, gradient, show_value } => {
|
||||
if let Some(n) = v.as_f64() {
|
||||
let lo = threshold(wb, sheet, c, &st, min);
|
||||
let hi = threshold(wb, sheet, c, &st, max);
|
||||
let lo = threshold(wb, sheet, c, st, min);
|
||||
let hi = threshold(wb, sheet, c, st, max);
|
||||
let lo = if lo > 0.0 { 0.0 } else { lo };
|
||||
let frac = if hi > lo { ((n - lo) / (hi - lo)).clamp(0.0, 1.0) } else { 1.0 };
|
||||
if out.bar.is_none() {
|
||||
@@ -270,7 +316,7 @@ impl CfCache {
|
||||
defaults.get(1..).unwrap_or_default()
|
||||
}
|
||||
};
|
||||
let cuts: Vec<f64> = lower.iter().map(|t| threshold(wb, sheet, c, &st, t)).collect();
|
||||
let cuts: Vec<f64> = lower.iter().map(|t| threshold(wb, sheet, c, st, t)).collect();
|
||||
let mut idx = cuts.iter().filter(|cut| n >= **cut).count();
|
||||
if *reverse {
|
||||
idx = k - 1 - idx.min(k - 1);
|
||||
@@ -397,6 +443,105 @@ mod tests {
|
||||
assert_eq!([look("A1"), look("A2"), look("A4")].map(border), [true, false, false]);
|
||||
}
|
||||
|
||||
/// The statistics as every rule used to get them: one pass collecting both parts.
|
||||
fn all_stats_before(sh: &Sheet, ranges: &[RangeRef]) -> RuleStats {
|
||||
let mut nums = Vec::new();
|
||||
let mut counts = HashMap::new();
|
||||
for r in ranges {
|
||||
let Some(r) = sh.used_range().and_then(|u| r.intersection(&u)) else { continue };
|
||||
for c in r.iter() {
|
||||
let v = sh.value(c);
|
||||
if let Value::Number(n) = v {
|
||||
nums.push(n);
|
||||
}
|
||||
if !v.is_empty() {
|
||||
*counts.entry(v.display().to_lowercase()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
nums.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let n = nums.len().max(1) as f64;
|
||||
let avg = nums.iter().sum::<f64>() / n;
|
||||
let std = (nums.iter().map(|x| (x - avg).powi(2)).sum::<f64>() / n).sqrt();
|
||||
RuleStats { sorted: nums, avg, std, counts }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needed_statistics_equal_the_full_ones() {
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[3, "x"], [7, "X"], [3, null], ["=NA()", 2.5], [" ", 9], [-1, "y"]]})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.sheet(0).unwrap();
|
||||
for ranges in ["A1:B6", "A1:A3", "B2:B9", "D1:E4", "A1:A2"].map(|r| vec![RangeRef::parse(r).unwrap()]) {
|
||||
let full = all_stats_before(sh, &ranges);
|
||||
let numbers = RuleStats::build(sh, &ranges, StatsNeed::Numbers);
|
||||
assert_eq!((numbers.sorted, numbers.avg.to_bits(), numbers.std.to_bits()), (full.sorted, full.avg.to_bits(), full.std.to_bits()));
|
||||
assert_eq!(RuleStats::build(sh, &ranges, StatsNeed::Counts).counts, full.counts);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rules_that_read_statistics_still_match() {
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1, "a"], [2, "A"], [3, "b"], [10, "c"], [2, null]]})).unwrap();
|
||||
let rule = |range: &str, r: serde_json::Value| json!({"range": range, "rule": r});
|
||||
s.execute("home.conditionalFormat", rule("B1:B5", json!({"type": "duplicate", "preset": "lightRedFill"}))).unwrap();
|
||||
s.execute("home.conditionalFormat", rule("A1:A5", json!({"type": "top10", "rank": 1, "preset": "redBorder"}))).unwrap();
|
||||
s.execute("home.conditionalFormat", rule("A1:A5", json!({"type": "colorScale"}))).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let mut cache = CfCache::new();
|
||||
let mut look = |a: &str| cache.look(&d.wb, 0, CellRef::parse(a).unwrap());
|
||||
let fill = |l: Option<CfLook>| l.and_then(|l| l.style).is_some_and(|st| st.fill.pattern == PatternType::Solid);
|
||||
// Duplicates are case-insensitive texts: "a" and "A".
|
||||
assert_eq!(["B1", "B2", "B3", "B4", "B5"].map(|a| fill(look(a))), [true, true, false, false, false]);
|
||||
// The top value gets the border, the scale runs from the minimum to the maximum.
|
||||
assert!(look("A4").and_then(|l| l.style).is_some_and(|st| !st.borders.top.is_none()));
|
||||
assert!(look("A1").and_then(|l| l.style).is_none_or(|st| st.borders.top.is_none()));
|
||||
assert_eq!(look("A1").and_then(|l| l.scale_fill), Some([0xF8, 0x69, 0x6B]));
|
||||
assert_eq!(look("A4").and_then(|l| l.scale_fill), Some([0x63, 0xBE, 0x7B]));
|
||||
}
|
||||
|
||||
/// Looking cells up does work in proportion to the cells asked for, not the rule's range: the
|
||||
/// statistics are only gathered for rules that read them, and once per rule.
|
||||
#[test]
|
||||
fn lookups_do_not_scan_the_range_for_rules_without_statistics() {
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
let rows: Vec<serde_json::Value> = (1..=10_000).map(|i| json!([i, i % 7])).collect();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
|
||||
s.execute(
|
||||
"home.conditionalFormat",
|
||||
json!({"range": "A1:B10000", "rule": {"type": "expression", "formula": "=MOD(ROW(),2)=0", "preset": "lightRedFill"}}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute(
|
||||
"home.conditionalFormat",
|
||||
json!({"range": "A1:B10000", "rule": {"type": "cellIs", "operator": "greater", "value": "5", "preset": "redBorder"}}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute("home.conditionalFormat", json!({"range": "A1:B10000", "rule": {"type": "noBlanks", "preset": "redBorder"}})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let mut cache = CfCache::new();
|
||||
SCANNED.with(|n| n.set(0));
|
||||
for r in 1..=50 {
|
||||
for col in ["A", "B"] {
|
||||
cache.look(&d.wb, 0, CellRef::parse(&format!("{col}{r}")).unwrap());
|
||||
}
|
||||
}
|
||||
assert_eq!(SCANNED.with(|n| n.get()), 0);
|
||||
// A rule that does read them scans its range once, however many cells are looked up.
|
||||
s.execute("home.conditionalFormat", json!({"range": "A1:A10000", "rule": {"type": "top10", "preset": "redBorder"}})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let mut cache = CfCache::new();
|
||||
SCANNED.with(|n| n.set(0));
|
||||
for r in 1..=50 {
|
||||
cache.look(&d.wb, 0, CellRef::parse(&format!("A{r}")).unwrap());
|
||||
}
|
||||
assert_eq!(SCANNED.with(|n| n.get()), 10_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_value_operator_names() {
|
||||
let mut s = crate::Session::new();
|
||||
@@ -410,4 +555,28 @@ mod tests {
|
||||
let mut look = |a: &str| cache.look(&d.wb, 0, CellRef::parse(a).unwrap()).and_then(|l| l.style).is_some();
|
||||
assert_eq!([look("A1"), look("A2")], [true, false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_rules_skip_error_values() {
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
let values = json!([["=NA()", "=NA()", "=1/0", "=NA()"], ["abc", "#x", "x!", "abc"], ["xyz", "y", "y", "xyz"]]);
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": values})).unwrap();
|
||||
let rule = |range: &str, kind: &str, text: &str| json!({"range": range, "rule": {"type": kind, "text": text, "preset": "lightRedFill"}});
|
||||
s.execute("home.conditionalFormat", rule("A1:A3", "containsText", "a")).unwrap();
|
||||
s.execute("home.conditionalFormat", rule("B1:B3", "beginsWith", "#")).unwrap();
|
||||
s.execute("home.conditionalFormat", rule("C1:C3", "endsWith", "!")).unwrap();
|
||||
s.execute("home.conditionalFormat", rule("D1:D3", "notContainsText", "a")).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let mut cache = CfCache::new();
|
||||
let mut look = |a: &str| cache.look(&d.wb, 0, CellRef::parse(a).unwrap()).and_then(|l| l.style).is_some();
|
||||
// Contains "a": abc, never #N/A although its text holds an "a".
|
||||
assert_eq!([look("A1"), look("A2"), look("A3")], [false, true, false]);
|
||||
// Begins with "#": #x, never #N/A.
|
||||
assert_eq!([look("B1"), look("B2"), look("B3")], [false, true, false]);
|
||||
// Ends with "!": x!, never #DIV/0!.
|
||||
assert_eq!([look("C1"), look("C2"), look("C3")], [false, true, false]);
|
||||
// Does not contain "a": #N/A and xyz, not abc.
|
||||
assert_eq!([look("D1"), look("D2"), look("D3")], [true, false, true]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
//! Charts as inline SVG for Save as Web Page: the chart crate's drawing primitives, text included.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use gridcraft_chart::{HAlign, Prim, Rgba, VAlign};
|
||||
use gridcraft_model::{Chart, Workbook};
|
||||
|
||||
use crate::io::esc;
|
||||
|
||||
/// Widest or tallest chart written; larger anchors are clamped.
|
||||
const MAX_SIDE: f32 = 16_384.0;
|
||||
|
||||
struct HelveticaMeasure;
|
||||
|
||||
impl gridcraft_chart::Measure for HelveticaMeasure {
|
||||
fn text_width(&self, text: &str, size: f32, bold: bool) -> f32 {
|
||||
gridcraft_pdf::text_width(if bold { gridcraft_pdf::Font::HelveticaBold } else { gridcraft_pdf::Font::Helvetica }, size, text)
|
||||
}
|
||||
}
|
||||
|
||||
/// `chart` of sheet `sheet` as an `<svg>` element placed by `style`, or `None` for a degenerate size.
|
||||
pub(crate) fn chart_svg(wb: &Workbook, sheet: usize, chart: &Chart, style: &str) -> Option<String> {
|
||||
let (w, h) = (chart.anchor.width.min(MAX_SIDE), chart.anchor.height.min(MAX_SIDE));
|
||||
if !(w >= 1.0 && h >= 1.0) {
|
||||
return None;
|
||||
}
|
||||
let data = gridcraft_chart::resolve(wb, sheet, chart);
|
||||
let prims = gridcraft_chart::render(chart, &data, w, h, &HelveticaMeasure);
|
||||
Some(prims_svg(&prims, w, h, style))
|
||||
}
|
||||
|
||||
fn prims_svg(prims: &[Prim], w: f32, h: f32, style: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let _ = write!(
|
||||
out,
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w}\" height=\"{h}\" viewBox=\"0 0 {w} {h}\" font-family=\"Helvetica,Arial,sans-serif\" style=\"{style}\">"
|
||||
);
|
||||
for p in prims {
|
||||
match p {
|
||||
Prim::Rect { x, y, w, h, fill, stroke, radius } => {
|
||||
let _ = write!(out, "<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\"", num(*x), num(*y), num(*w), num(*h));
|
||||
if *radius > 0.0 {
|
||||
let _ = write!(out, " rx=\"{}\"", num(*radius));
|
||||
}
|
||||
paint(&mut out, *fill, *stroke);
|
||||
out.push_str("/>");
|
||||
}
|
||||
Prim::Line { pts, color, width, dash } => {
|
||||
let _ = write!(out, "<polyline points=\"{}\"", points(pts));
|
||||
paint(&mut out, None, Some((*color, *width)));
|
||||
if *dash {
|
||||
out.push_str(" stroke-dasharray=\"3 2\"");
|
||||
}
|
||||
out.push_str("/>");
|
||||
}
|
||||
Prim::Polygon { pts, fill, stroke } => {
|
||||
let _ = write!(out, "<polygon points=\"{}\"", points(pts));
|
||||
paint(&mut out, Some(*fill), *stroke);
|
||||
out.push_str("/>");
|
||||
}
|
||||
Prim::Wedge { cx, cy, r_outer, r_inner, a0, a1, fill, stroke } => {
|
||||
let _ = write!(out, "<path d=\"{}\"", wedge_path(*cx, *cy, *r_outer, *r_inner, *a0, *a1));
|
||||
paint(&mut out, Some(*fill), *stroke);
|
||||
out.push_str("/>");
|
||||
}
|
||||
Prim::Circle { cx, cy, r, fill, stroke } => {
|
||||
let _ = write!(out, "<circle cx=\"{}\" cy=\"{}\" r=\"{}\"", num(*cx), num(*cy), num(*r));
|
||||
paint(&mut out, *fill, *stroke);
|
||||
out.push_str("/>");
|
||||
}
|
||||
Prim::Text { x, y, text, size, color, bold, align, valign, rotation } => {
|
||||
// Baseline offsets as in the PDF export.
|
||||
let dy = match valign {
|
||||
VAlign::Top => size * 0.78,
|
||||
VAlign::Middle => size * 0.35,
|
||||
VAlign::Bottom => -size * 0.21,
|
||||
};
|
||||
let anchor = match align {
|
||||
HAlign::Left => "start",
|
||||
HAlign::Center => "middle",
|
||||
HAlign::Right => "end",
|
||||
};
|
||||
let _ = write!(out, "<text x=\"{}\" y=\"{}\" font-size=\"{}\" text-anchor=\"{anchor}\"", num(*x), num(y + dy), num(*size));
|
||||
if *bold {
|
||||
out.push_str(" font-weight=\"bold\"");
|
||||
}
|
||||
if rotation.is_finite() && *rotation != 0.0 {
|
||||
let _ = write!(out, " transform=\"rotate({} {} {})\"", num(rotation.to_degrees()), num(*x), num(*y));
|
||||
}
|
||||
let _ = write!(out, " fill=\"{}\"{}>{}</text>", hex(*color), opacity("fill", *color), esc(text));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str("</svg>");
|
||||
out
|
||||
}
|
||||
|
||||
/// Fill and stroke attributes of an open shape element; the caller closes it.
|
||||
fn paint(out: &mut String, fill: Option<Rgba>, stroke: Option<(Rgba, f32)>) {
|
||||
match fill {
|
||||
Some(c) => {
|
||||
let _ = write!(out, " fill=\"{}\"{}", hex(c), opacity("fill", c));
|
||||
}
|
||||
None => out.push_str(" fill=\"none\""),
|
||||
}
|
||||
if let Some((c, w)) = stroke {
|
||||
let _ = write!(out, " stroke=\"{}\" stroke-width=\"{}\"{}", hex(c), num(w), opacity("stroke", c));
|
||||
}
|
||||
}
|
||||
|
||||
/// A pie or doughnut sector. Angles run clockwise from 12 o'clock, like the chart crate's.
|
||||
fn wedge_path(cx: f32, cy: f32, r_outer: f32, r_inner: f32, a0: f32, a1: f32) -> String {
|
||||
let span = (a1 - a0).clamp(-std::f32::consts::TAU + 0.001, std::f32::consts::TAU - 0.001);
|
||||
let a1 = a0 + span;
|
||||
let at = |a: f32, r: f32| format!("{} {}", num(cx + r * a.sin()), num(cy - r * a.cos()));
|
||||
let large = u8::from(span.abs() > std::f32::consts::PI);
|
||||
let sweep = u8::from(span >= 0.0);
|
||||
let mut d = format!("M{} A{r} {r} 0 {large} {sweep} {}", at(a0, r_outer), at(a1, r_outer), r = num(r_outer));
|
||||
if r_inner > 0.0 {
|
||||
let _ = write!(d, " L{} A{r} {r} 0 {large} {} {} Z", at(a1, r_inner), 1 - sweep, at(a0, r_inner), r = num(r_inner));
|
||||
} else {
|
||||
let _ = write!(d, " L{} {} Z", num(cx), num(cy));
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn points(pts: &[[f32; 2]]) -> String {
|
||||
pts.iter().map(|p| format!("{},{}", num(p[0]), num(p[1]))).collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
fn hex(c: Rgba) -> String {
|
||||
format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2])
|
||||
}
|
||||
|
||||
fn opacity(attr: &str, c: Rgba) -> String {
|
||||
if c[3] == 255 { String::new() } else { format!(" {attr}-opacity=\"{:.2}\"", f32::from(c[3]) / 255.0) }
|
||||
}
|
||||
|
||||
/// A coordinate with two decimals; a non-finite value is 0 so the SVG stays well-formed.
|
||||
fn num(v: f32) -> String {
|
||||
if !v.is_finite() {
|
||||
return "0".into();
|
||||
}
|
||||
let s = format!("{v:.2}");
|
||||
s.trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_primitive_becomes_an_svg_element() {
|
||||
let red = [255, 0, 0, 255];
|
||||
let prims = vec![
|
||||
Prim::Rect { x: 1.0, y: 2.0, w: 3.0, h: 4.0, fill: Some(red), stroke: None, radius: 0.0 },
|
||||
Prim::Line { pts: vec![[0.0, 0.0], [5.0, 5.5]], color: red, width: 1.0, dash: true },
|
||||
Prim::Polygon { pts: vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]], fill: red, stroke: Some(([0, 0, 0, 255], 1.0)) },
|
||||
Prim::Wedge { cx: 10.0, cy: 10.0, r_outer: 5.0, r_inner: 2.0, a0: 0.0, a1: 1.0, fill: red, stroke: None },
|
||||
Prim::Circle { cx: 1.0, cy: 1.0, r: 2.0, fill: Some([0, 0, 255, 128]), stroke: None },
|
||||
Prim::Text {
|
||||
x: 5.0,
|
||||
y: 6.0,
|
||||
text: "A<B".into(),
|
||||
size: 10.0,
|
||||
color: red,
|
||||
bold: true,
|
||||
align: HAlign::Center,
|
||||
valign: VAlign::Top,
|
||||
rotation: -1.5,
|
||||
},
|
||||
];
|
||||
let svg = prims_svg(&prims, 100.0, 50.0, "left:1px");
|
||||
for part in [
|
||||
"<rect x=\"1\" y=\"2\" width=\"3\" height=\"4\" fill=\"#ff0000\"/>",
|
||||
"<polyline points=\"0,0 5,5.5\" fill=\"none\" stroke=\"#ff0000\" stroke-width=\"1\" stroke-dasharray=\"3 2\"/>",
|
||||
"<polygon ",
|
||||
"<path d=\"M10 5 A5 5 0 0 1 ",
|
||||
"fill-opacity=\"0.50\"",
|
||||
"text-anchor=\"middle\"",
|
||||
"font-weight=\"bold\"",
|
||||
"transform=\"rotate(-85.94 5 6)\"",
|
||||
">A<B</text>",
|
||||
] {
|
||||
assert!(svg.contains(part), "{part} in {svg}");
|
||||
}
|
||||
assert!(svg.starts_with("<svg ") && svg.ends_with("</svg>") && svg.contains("style=\"left:1px\""), "{svg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_finite_numbers_do_not_break_the_markup() {
|
||||
let prims = [Prim::Circle { cx: f32::NAN, cy: f32::INFINITY, r: 1.0, fill: None, stroke: None }];
|
||||
assert!(prims_svg(&prims, 10.0, 10.0, "").contains("<circle cx=\"0\" cy=\"0\" r=\"1\" fill=\"none\"/>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_circle_wedge_keeps_a_drawable_arc() {
|
||||
let d = wedge_path(0.0, 0.0, 10.0, 0.0, 0.0, std::f32::consts::TAU);
|
||||
assert!(d.contains("A10 10 0 1 1 ") && d.ends_with("L0 0 Z"), "{d}");
|
||||
}
|
||||
}
|
||||
+166
-42
@@ -11,6 +11,11 @@
|
||||
const MAX_HTML: usize = 16 * 1024 * 1024;
|
||||
/// Widest `colspan` honoured (the sheet's column count).
|
||||
const MAX_SPAN: usize = gridcraft_core::MAX_COLS as usize;
|
||||
/// Tallest `rowspan` honoured (the sheet's row count).
|
||||
const MAX_ROW_SPAN: usize = gridcraft_core::MAX_ROWS as usize;
|
||||
/// Most cells, span placeholders included, one paste reads from the HTML. Past it the rest of the
|
||||
/// table is ignored: the HTML only refines cells of the tab-separated text, which stays the source.
|
||||
const MAX_HTML_CELLS: usize = 1_000_000;
|
||||
|
||||
/// One table cell: its visible text and the number the source recorded for it, if any.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
@@ -19,25 +24,28 @@ pub(crate) struct HtmlCell {
|
||||
pub num: Option<f64>,
|
||||
}
|
||||
|
||||
/// The cells of the first table in `html`, row by row; a cell spanning columns is followed by
|
||||
/// empty cells so positions line up with the tab-separated text. Empty when there is no table.
|
||||
pub(crate) fn table_cells(html: &str) -> Vec<Vec<HtmlCell>> {
|
||||
let mut rows: Vec<Vec<HtmlCell>> = Vec::new();
|
||||
/// The cells of the first table in `html`, row by row, read no further than `max_rows` rows of
|
||||
/// `max_cols` cells (the pasted text's shape, itself clamped to the sheet) and [`MAX_HTML_CELLS`]
|
||||
/// in all. A cell spanning columns is followed by empty cells, and a column covered by a `rowspan`
|
||||
/// from above holds an empty cell, so positions line up with the tab-separated text. Empty when
|
||||
/// there is no table.
|
||||
pub(crate) fn table_cells(html: &str, max_rows: usize, max_cols: usize) -> Vec<Vec<HtmlCell>> {
|
||||
let mut grid = Grid::new(max_rows, max_cols);
|
||||
if html.len() > MAX_HTML {
|
||||
return rows;
|
||||
return grid.rows;
|
||||
}
|
||||
let mut cell: Option<(HtmlCell, usize)> = None;
|
||||
let mut cell: Option<OpenCell> = None;
|
||||
let mut in_table = false;
|
||||
let mut rest = html;
|
||||
while !rest.is_empty() {
|
||||
while !rest.is_empty() && !grid.full {
|
||||
let Some(lt) = rest.find('<') else {
|
||||
if let Some((c, _)) = cell.as_mut() {
|
||||
c.text.push_str(rest);
|
||||
if let Some(c) = cell.as_mut() {
|
||||
c.cell.text.push_str(rest);
|
||||
}
|
||||
break;
|
||||
};
|
||||
if let Some((c, _)) = cell.as_mut() {
|
||||
c.text.push_str(rest.get(..lt).unwrap_or_default());
|
||||
if let Some(c) = cell.as_mut() {
|
||||
c.cell.text.push_str(rest.get(..lt).unwrap_or_default());
|
||||
}
|
||||
rest = rest.get(lt..).unwrap_or_default();
|
||||
if let Some(after) = rest.strip_prefix("<!--") {
|
||||
@@ -65,49 +73,131 @@ pub(crate) fn table_cells(html: &str) -> Vec<Vec<HtmlCell>> {
|
||||
in_table = true;
|
||||
}
|
||||
("table", true) => {
|
||||
finish(&mut rows, &mut cell);
|
||||
if !rows.is_empty() {
|
||||
grid.finish(&mut cell);
|
||||
if !grid.rows.is_empty() {
|
||||
break;
|
||||
}
|
||||
in_table = false;
|
||||
}
|
||||
("tr", false) if in_table => {
|
||||
finish(&mut rows, &mut cell);
|
||||
rows.push(Vec::new());
|
||||
grid.finish(&mut cell);
|
||||
grid.start_row();
|
||||
}
|
||||
("td" | "th", false) if in_table => {
|
||||
finish(&mut rows, &mut cell);
|
||||
if rows.is_empty() {
|
||||
rows.push(Vec::new());
|
||||
}
|
||||
grid.finish(&mut cell);
|
||||
let num = attr(attrs, "x:num").or_else(|| attr(attrs, "sdval")).and_then(|v| v.trim().parse::<f64>().ok()).filter(|n| n.is_finite());
|
||||
let span = attr(attrs, "colspan").and_then(|v| v.trim().parse::<usize>().ok()).unwrap_or(1).clamp(1, MAX_SPAN);
|
||||
cell = Some((HtmlCell { text: String::new(), num }, span));
|
||||
let colspan = span(attrs, "colspan", MAX_SPAN);
|
||||
let rowspan = span(attrs, "rowspan", MAX_ROW_SPAN);
|
||||
cell = Some(OpenCell { cell: HtmlCell { text: String::new(), num }, colspan, rowspan });
|
||||
}
|
||||
("td" | "th", true) | ("tr", true) => finish(&mut rows, &mut cell),
|
||||
("td" | "th", true) | ("tr", true) => grid.finish(&mut cell),
|
||||
("br", _) => {
|
||||
if let Some((c, _)) = cell.as_mut() {
|
||||
c.text.push(' ');
|
||||
if let Some(c) = cell.as_mut() {
|
||||
c.cell.text.push(' ');
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
finish(&mut rows, &mut cell);
|
||||
rows
|
||||
grid.finish(&mut cell);
|
||||
grid.rows
|
||||
}
|
||||
|
||||
/// Closes the open cell (if any) into the last row, decoding its text.
|
||||
fn finish(rows: &mut Vec<Vec<HtmlCell>>, cell: &mut Option<(HtmlCell, usize)>) {
|
||||
let Some((mut c, span)) = cell.take() else { return };
|
||||
c.text = decode(&c.text);
|
||||
if rows.is_empty() {
|
||||
rows.push(Vec::new());
|
||||
/// A `colspan`/`rowspan` attribute: at least 1, at most `max` (the sheet's extent), so a hostile
|
||||
/// "999999999999" is clamped before anything is expanded.
|
||||
fn span(attrs: &str, name: &str, max: usize) -> usize {
|
||||
attr(attrs, name).and_then(|v| v.trim().parse::<usize>().ok()).unwrap_or(1).clamp(1, max)
|
||||
}
|
||||
|
||||
/// A cell whose closing tag hasn't been seen yet.
|
||||
struct OpenCell {
|
||||
cell: HtmlCell,
|
||||
colspan: usize,
|
||||
rowspan: usize,
|
||||
}
|
||||
|
||||
/// The table being read, with its size limits.
|
||||
struct Grid {
|
||||
rows: Vec<Vec<HtmlCell>>,
|
||||
max_rows: usize,
|
||||
max_cols: usize,
|
||||
/// Cells still allowed before the table is cut short.
|
||||
budget: usize,
|
||||
/// Per column: the row index (exclusive) down to which a `rowspan` from above covers it.
|
||||
covered_until: Vec<usize>,
|
||||
/// The row or cell budget ran out: nothing more is read.
|
||||
full: bool,
|
||||
}
|
||||
|
||||
impl Grid {
|
||||
fn new(max_rows: usize, max_cols: usize) -> Self {
|
||||
let max_rows = max_rows.min(MAX_ROW_SPAN);
|
||||
let max_cols = max_cols.min(MAX_SPAN);
|
||||
let budget = MAX_HTML_CELLS.min(max_rows.saturating_mul(max_cols));
|
||||
Grid { rows: Vec::new(), max_rows, max_cols, budget, covered_until: Vec::new(), full: false }
|
||||
}
|
||||
if let Some(row) = rows.last_mut() {
|
||||
row.push(c);
|
||||
let room = MAX_SPAN.saturating_sub(row.len());
|
||||
row.extend(std::iter::repeat_n(HtmlCell::default(), span.saturating_sub(1).min(room)));
|
||||
|
||||
/// Opens a new row, or marks the grid full when the pasted text has no more rows.
|
||||
fn start_row(&mut self) {
|
||||
if self.rows.len() >= self.max_rows {
|
||||
self.full = true;
|
||||
} else {
|
||||
self.rows.push(Vec::new());
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends one cell to the current row; false when the row or the cell budget is used up.
|
||||
fn push(&mut self, cell: HtmlCell) -> bool {
|
||||
let Some(row) = self.rows.last_mut() else { return false };
|
||||
if row.len() >= self.max_cols {
|
||||
return false;
|
||||
}
|
||||
let Some(left) = self.budget.checked_sub(1) else {
|
||||
self.full = true;
|
||||
return false;
|
||||
};
|
||||
self.budget = left;
|
||||
row.push(cell);
|
||||
true
|
||||
}
|
||||
|
||||
/// Closes the open cell (if any) into the current row, decoding its text: first past the
|
||||
/// columns a `rowspan` above still covers, then the cell, then a placeholder per extra column.
|
||||
fn finish(&mut self, open: &mut Option<OpenCell>) {
|
||||
let Some(OpenCell { mut cell, colspan, rowspan }) = open.take() else { return };
|
||||
if self.rows.is_empty() {
|
||||
self.start_row();
|
||||
}
|
||||
let Some(r) = self.rows.len().checked_sub(1) else { return };
|
||||
while self.covered_until.get(self.col()).is_some_and(|&until| until > r) {
|
||||
if !self.push(HtmlCell::default()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let start = self.col();
|
||||
cell.text = decode(&cell.text);
|
||||
if !self.push(cell) {
|
||||
return;
|
||||
}
|
||||
for _ in 1..colspan {
|
||||
if !self.push(HtmlCell::default()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if rowspan > 1 {
|
||||
let (end, until) = (self.col(), r.saturating_add(rowspan));
|
||||
if self.covered_until.len() < end {
|
||||
self.covered_until.resize(end, 0);
|
||||
}
|
||||
for u in self.covered_until.get_mut(start..end).into_iter().flatten() {
|
||||
*u = (*u).max(until);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The column the next cell of the current row lands in.
|
||||
fn col(&self) -> usize {
|
||||
self.rows.last().map_or(0, Vec::len)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,7 +366,7 @@ mod tests {
|
||||
let excel = r#"<html><head><style>td{mso-number-format:General}</style></head><body><table>
|
||||
<!--StartFragment--><tr height=20><td class=xl65 x:num="10000">Rp10,000.00</td><td x:str>Name</td></tr>
|
||||
<tr><td colspan=2 x:num="2.5">2.50 & up</td><td>x</td></tr><!--EndFragment--></table></body></html>"#;
|
||||
let rows = table_cells(excel);
|
||||
let rows = table_cells(excel, 2, 3);
|
||||
assert_eq!(rows.len(), 2);
|
||||
assert_eq!(rows[0][0], HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) });
|
||||
assert_eq!(rows[0][1], HtmlCell { text: "Name".into(), num: None });
|
||||
@@ -286,10 +376,10 @@ mod tests {
|
||||
|
||||
let calc = r#"<table cellspacing="0" border="0"><colgroup width="85"></colgroup><tr>
|
||||
<td height="17" align="right" sdval="10000" sdnum="1033;0;[$Rp-421]#,##0.00"><font face="Liberation Sans">Rp10,000.00</font></td></tr></table>"#;
|
||||
assert_eq!(table_cells(calc), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }]]);
|
||||
assert_eq!(table_cells(calc, 1, 1), vec![vec![HtmlCell { text: "Rp10,000.00".into(), num: Some(10000.0) }]]);
|
||||
|
||||
let quoted_gt = r#"<table><tr><td style='mso-number-format:"\[>=1000\]0"' x:num="1500">1500</td></tr></table>"#;
|
||||
assert_eq!(table_cells(quoted_gt), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0) }]]);
|
||||
assert_eq!(table_cells(quoted_gt, 1, 1), vec![vec![HtmlCell { text: "1500".into(), num: Some(1500.0) }]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -306,10 +396,44 @@ mod tests {
|
||||
"<style>",
|
||||
"<table><table><td>",
|
||||
] {
|
||||
let _ = table_cells(html);
|
||||
let _ = table_cells(html, MAX_ROW_SPAN, MAX_SPAN);
|
||||
}
|
||||
assert_eq!(table_cells("<table><td x:num='1e999'>1</td></table>")[0][0].num, None);
|
||||
assert!(table_cells("<table><tr><td colspan=999999999>a</td></tr></table>")[0].len() <= MAX_SPAN);
|
||||
assert_eq!(table_cells("<table><td x:num='1e999'>1</td></table>", 1, 1)[0][0].num, None);
|
||||
assert!(table_cells("<table><tr><td colspan=999999999>a</td></tr></table>", 1, usize::MAX)[0].len() <= MAX_SPAN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_spans_stay_within_the_cell_budget() {
|
||||
// #2171: 100,000 rows of `colspan=16384` once expanded to ~1.6 billion cells (OOM). Even
|
||||
// with the sheet's full extent as the limit, at most MAX_HTML_CELLS are materialized.
|
||||
let row = "<tr><td colspan=16384 rowspan=99999999999 x:num='1'>1</td><td colspan=4294967296>2</td></tr>";
|
||||
let html = format!("<table>{}</table>", row.repeat(100_000));
|
||||
let rows = table_cells(&html, usize::MAX, usize::MAX);
|
||||
let cells: usize = rows.iter().map(Vec::len).sum();
|
||||
assert!(cells <= MAX_HTML_CELLS, "{cells} cells");
|
||||
assert!(rows.iter().all(|r| r.len() <= MAX_SPAN));
|
||||
assert_eq!(rows[0].len(), MAX_SPAN, "the first span fills the sheet's width");
|
||||
assert_eq!(rows[0][0], HtmlCell { text: "1".into(), num: Some(1.0) });
|
||||
|
||||
// Bounded by the pasted text's shape, the same markup yields just that shape.
|
||||
let rows = table_cells(&html, 3, 2);
|
||||
assert_eq!(rows.iter().map(Vec::len).collect::<Vec<_>>(), [2, 2, 2]);
|
||||
assert_eq!(rows[0][0].num, Some(1.0));
|
||||
assert!(table_cells(&html, 0, 5).is_empty());
|
||||
assert!(table_cells(&html, 4, 0).iter().all(Vec::is_empty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rowspan_keeps_later_rows_in_their_columns() {
|
||||
// A merged A1:A2 is one `rowspan=2` cell; row 2 omits it, and its text has an empty field there.
|
||||
let html = r#"<table><tr><td rowspan=2 colspan=2>Merged</td><td x:num="1">1</td></tr>
|
||||
<tr><td x:num="2">2</td></tr><tr><td x:num="3">3</td><td>c</td></tr></table>"#;
|
||||
let rows = table_cells(html, 3, 3);
|
||||
assert_eq!(rows.iter().map(Vec::len).collect::<Vec<_>>(), [3, 3, 2]);
|
||||
assert_eq!(rows[0][2].num, Some(1.0));
|
||||
assert_eq!((rows[1][0].clone(), rows[1][1].clone()), (HtmlCell::default(), HtmlCell::default()));
|
||||
assert_eq!(rows[1][2].num, Some(2.0));
|
||||
assert_eq!(rows[2][0].num, Some(3.0), "the span ends after its second row");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -782,15 +782,24 @@ fn forecast_grid(x: &[f64], f: &[Option<f64>], se_window: usize, p: &Json) -> Gr
|
||||
g.put(idx(i), 0, fi.map(Value::number).unwrap_or_else(na));
|
||||
}
|
||||
if flag(p, "standardErrors") {
|
||||
// A running sum of squared residuals over the last `se_window` points: O(n), not
|
||||
// O(n·window). A point without a forecast restarts it, so every window touching it is #N/A.
|
||||
let sq = |j: usize| Some((x.get(j)? - (*f.get(j)?)?).powi(2));
|
||||
let (mut acc, mut run) = (0.0, 0usize);
|
||||
for i in 0..f.len() {
|
||||
let start = (i + 1).checked_sub(se_window);
|
||||
let v = start.and_then(|s0| {
|
||||
let mut acc = 0.0;
|
||||
for j in s0..=i {
|
||||
acc += (x.get(j)? - (*f.get(j)?)?).powi(2);
|
||||
match sq(i) {
|
||||
Some(t) => {
|
||||
acc += t;
|
||||
run += 1;
|
||||
}
|
||||
Some((acc / se_window as f64).sqrt())
|
||||
});
|
||||
None => (acc, run) = (0.0, 0),
|
||||
}
|
||||
if run > se_window {
|
||||
acc -= i.checked_sub(se_window).and_then(sq).unwrap_or(0.0);
|
||||
run = se_window;
|
||||
}
|
||||
// Rounding in the subtraction can leave a tiny negative sum.
|
||||
let v = (run == se_window).then(|| (acc.max(0.0) / se_window as f64).sqrt());
|
||||
g.put(idx(i), 1, v.map(Value::number).unwrap_or_else(na));
|
||||
}
|
||||
}
|
||||
@@ -1190,6 +1199,37 @@ mod tests {
|
||||
x
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forecast_standard_errors_match_the_full_window_rescan() {
|
||||
// #1043: the running sum must give what re-adding every window gave, gaps included.
|
||||
let x: [f64; 11] = [2.0, 4.0, 5.0, 4.0, 5.0, 7.0, 1.0, 3.0, 8.0, 6.0, 2.0];
|
||||
let f = [None, Some(3.0), Some(4.5), None, Some(4.0), Some(6.5), Some(2.0), Some(2.5), Some(7.0), Some(5.0), Some(4.0)];
|
||||
let rescan = |i: usize, w: usize| -> Value {
|
||||
let v = (i + 1).checked_sub(w).and_then(|s0| {
|
||||
let mut acc = 0.0;
|
||||
for j in s0..=i {
|
||||
acc += (x.get(j)? - (*f.get(j)?)?).powi(2);
|
||||
}
|
||||
Some((acc / w as f64).sqrt())
|
||||
});
|
||||
v.map(Value::number).unwrap_or(Value::Error(gridcraft_core::CellError::NA))
|
||||
};
|
||||
for w in 1..=5 {
|
||||
let g = super::forecast_grid(&x, &f, w, &json!({"standardErrors": true}));
|
||||
for i in 0..f.len() {
|
||||
let (got, want) = (g.cells.get(&(i as u32, 1)).cloned().unwrap_or(Value::Empty), rescan(i, w));
|
||||
match (got.as_f64(), want.as_f64()) {
|
||||
(Some(a), Some(b)) => assert!(close(a, b, 1e-12), "window {w}, row {i}: {a} vs {b}"),
|
||||
_ => assert_eq!(got, want, "window {w}, row {i}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
// Row 3 has no forecast: windows through it are #N/A, the next full window after it is not.
|
||||
let g = super::forecast_grid(&x, &f, 3, &json!({"standardErrors": true}));
|
||||
assert_eq!(g.cells.get(&(5, 1)), Some(&Value::Error(gridcraft_core::CellError::NA)));
|
||||
assert!(g.cells.get(&(6, 1)).and_then(Value::as_f64).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptive_statistics_of_one_to_five() {
|
||||
// 1..5: mean 3, s = √2.5, kurtosis −1.2, skewness 0 (textbook values).
|
||||
|
||||
+439
-55
@@ -80,13 +80,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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)}",
|
||||
"{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}", has_doc, |s, p| group(
|
||||
s, p, false
|
||||
)),
|
||||
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",
|
||||
@@ -147,6 +147,23 @@ fn data_range(s: &Session, p: &Json) -> Result<RangeRef> {
|
||||
Ok(current_region(sh, d.selection.active))
|
||||
}
|
||||
|
||||
/// The sheet and range a data command works on: the sheet `sheet` or a `Sheet!` prefix on `range`
|
||||
/// names, else the active one. The selection belongs to the active sheet, so another sheet needs `range`.
|
||||
fn data_target(s: &Session, p: &Json, cmd: &str) -> Result<(usize, RangeRef)> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let d = s.doc()?;
|
||||
if let Some(t) = str_param(p, "range")
|
||||
&& let (Some(name), _) = split_sheet(t)
|
||||
&& d.wb.sheet_index(&name) != Some(sheet)
|
||||
{
|
||||
return Err(bad(cmd, format!("`{t}` names a sheet other than `sheet`")));
|
||||
}
|
||||
if sheet != d.wb.active_sheet && p.get("range").is_none() {
|
||||
return Err(bad(cmd, "missing `range`: the selection is on the active sheet"));
|
||||
}
|
||||
Ok((sheet, data_range(s, p)?))
|
||||
}
|
||||
|
||||
/// Excel's header guess: the first row is text over a column of numbers, or is bold.
|
||||
fn guess_header(wb: &Workbook, sh: &Sheet, r: RangeRef) -> bool {
|
||||
if let Some(t) = sh.table_at(r.start) {
|
||||
@@ -182,19 +199,36 @@ struct SortKey {
|
||||
list: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// The sheet and range a sort works on: the sheet `sheet` or a `Sheet!` prefix on `range` names,
|
||||
/// else the active one. The selection belongs to the active sheet, so another sheet needs `range`.
|
||||
fn sort_target(s: &Session, p: &Json) -> Result<(usize, RangeRef)> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let d = s.doc()?;
|
||||
if let Some(t) = str_param(p, "range")
|
||||
&& let (Some(name), _) = split_sheet(t)
|
||||
&& d.wb.sheet_index(&name) != Some(sheet)
|
||||
{
|
||||
return Err(bad("data.sort", format!("`{t}` names a sheet other than `sheet`")));
|
||||
}
|
||||
if sheet != d.wb.active_sheet && p.get("range").is_none() {
|
||||
return Err(bad("data.sort", "missing `range`: the selection is on the active sheet"));
|
||||
}
|
||||
Ok((sheet, data_range(s, p)?))
|
||||
}
|
||||
|
||||
fn quick_sort(s: &mut Session, p: &Json, asc: bool) -> Result<Json> {
|
||||
let r = data_range(s, p)?;
|
||||
let (sheet, r) = sort_target(s, p)?;
|
||||
let col = col_param(p.get("column"), r.start.col).unwrap_or(s.doc()?.selection.active.col).clamp(r.start.col, r.end.col);
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
let header = guess_header(&d.wb, sh, r);
|
||||
do_sort(s, r, header, vec![SortKey { col, desc: !asc, by: "values".into(), list: None }], false, false)
|
||||
do_sort(s, sheet, r, header, vec![SortKey { col, desc: !asc, by: "values".into(), list: None }], false, false)
|
||||
}
|
||||
|
||||
fn sort(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let r = data_range(s, p)?;
|
||||
let (sheet, r) = sort_target(s, p)?;
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
let header = bool_param(p, "header").unwrap_or_else(|| guess_header(&d.wb, sh, r));
|
||||
let by_cols = str_param(p, "orientation") == Some("columns");
|
||||
let case = bool_param(p, "matchCase").unwrap_or(false);
|
||||
@@ -228,10 +262,12 @@ fn sort(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if keys.len() > 64 {
|
||||
return Err(bad("data.sort", "too many sort levels (64 max)"));
|
||||
}
|
||||
do_sort(s, r, header, keys, by_cols, case)
|
||||
do_sort(s, sheet, r, header, keys, by_cols, case)
|
||||
}
|
||||
|
||||
fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec<SortKey>, by_cols: bool, case: bool) -> Result<Json> {
|
||||
fn do_sort(s: &mut Session, sheet: usize, r: RangeRef, header: bool, keys: Vec<SortKey>, by_cols: bool, case: bool) -> Result<Json> {
|
||||
// Protect Sheet ▸ Sort. Both Sort… and the A-to-Z buttons come through here.
|
||||
super::refuse_unless_allowed(s, sheet, "Sorting", |p| p.sort)?;
|
||||
let body = if by_cols {
|
||||
if header && r.width() > 1 { RangeRef::new(CellRef::new(r.start.row, r.start.col + 1), r.end) } else { r }
|
||||
} else if header && r.height() > 1 {
|
||||
@@ -242,7 +278,6 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec<SortKey>, by_co
|
||||
if body.count() > 20_000_000 {
|
||||
return Err(bad("data.sort", "range too large"));
|
||||
}
|
||||
let sheet = s.doc()?.wb.active_sheet;
|
||||
edit(s, |cx| {
|
||||
let Some(sh) = cx.wb.sheet(sheet) else { return Ok(Json::Null) };
|
||||
if sh.merges.iter().any(|m| m.intersects(&body) && !m.is_single()) {
|
||||
@@ -295,6 +330,21 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec<SortKey>, by_co
|
||||
}
|
||||
// Move whole lines (cells with formulas adjusted for relative references).
|
||||
let lanes: Vec<u32> = if by_cols { (body.start.row..=body.end.row).collect() } else { (body.start.col..=body.end.col).collect() };
|
||||
// Notes and hyperlinks hang off a cell, not a row, so they travel with the row they
|
||||
// describe — the way cell pictures and row heights do. Collect them before inserting,
|
||||
// so a swap can't resurrect one from a destination that is also a source.
|
||||
let side: Vec<(CellRef, Comment)> = order
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(dst_i, &src_i)| lanes.iter().map(move |&k| (src_i, dst_i as u32, k)))
|
||||
.filter_map(|(src_i, dst_i, k)| sh.comments.get(&line(src_i, k)).map(|cm| (line(dst_i, k), cm.clone())))
|
||||
.collect();
|
||||
let links: Vec<(CellRef, Hyperlink)> = order
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(dst_i, &src_i)| lanes.iter().map(move |&k| (src_i, dst_i as u32, k)))
|
||||
.filter_map(|(src_i, dst_i, k)| sh.hyperlinks.get(&line(src_i, k)).map(|h| (line(dst_i, k), h.clone())))
|
||||
.collect();
|
||||
let mut moved: Vec<(CellRef, Option<Cell>)> = Vec::with_capacity(body.count() as usize);
|
||||
let mut pictures = Vec::new();
|
||||
for (dst_i, &src_i) in order.iter().enumerate() {
|
||||
@@ -354,6 +404,18 @@ fn do_sort(s: &mut Session, r: RangeRef, header: bool, keys: Vec<SortKey>, by_co
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every cell of the body is the destination of exactly one source cell, so clearing the
|
||||
// body and inserting the permuted entries is the same permutation the cells went through.
|
||||
for c in body.iter() {
|
||||
shm.comments.remove(&c);
|
||||
shm.hyperlinks.remove(&c);
|
||||
}
|
||||
for (at, cm) in side {
|
||||
shm.comments.insert(at, cm);
|
||||
}
|
||||
for (at, h) in links {
|
||||
shm.hyperlinks.insert(at, h);
|
||||
}
|
||||
shm.cell_pictures.extend(pictures);
|
||||
for (i, h) in heights.into_iter().enumerate() {
|
||||
let row = body.start.row + i as u32;
|
||||
@@ -437,10 +499,12 @@ fn sorted_anchor(sh: &Sheet, a: Anchor, bottom: (u32, u32, f32, f32), by_cols: b
|
||||
// ---------------------------------------------------------------- filter
|
||||
|
||||
fn toggle_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = s.doc()?.wb.active_sheet;
|
||||
let (sheet, r) = data_target(s, p, "data.filter")?;
|
||||
super::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?;
|
||||
// The active cell belongs to the active sheet: on another sheet the table is the one at `range`.
|
||||
let at = if sheet == s.doc()?.wb.active_sheet { s.doc()?.selection.active } else { r.start };
|
||||
let has = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.autofilter.is_some());
|
||||
let in_table =
|
||||
s.doc()?.wb.sheet(sheet).and_then(|sh| sh.table_at(s.doc().map(|d| d.selection.active).unwrap_or_default()).map(|t| t.name.clone()));
|
||||
let in_table = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.table_at(at).map(|t| t.name.clone()));
|
||||
if let Some(name) = in_table {
|
||||
return edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
@@ -473,8 +537,7 @@ fn toggle_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
/// Turn a sheet AutoFilter on over `p.range`, or over the data range of the selection.
|
||||
fn install_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = s.doc()?.wb.active_sheet;
|
||||
let mut r = data_range(s, p)?;
|
||||
let (sheet, mut r) = data_target(s, p, "data.filter")?;
|
||||
// A selected header row filters the data below it (#201).
|
||||
if p.get("range").is_none() && r.height() == 1 {
|
||||
let d = s.doc()?;
|
||||
@@ -492,6 +555,7 @@ fn install_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
fn filter_by(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = s.doc()?.wb.active_sheet;
|
||||
super::refuse_unless_allowed(s, sheet, "Filtering", |p| p.autofilter)?;
|
||||
let Some(sh) = s.doc()?.wb.sheet(sheet) else { return Err(EngineError::NoDocument) };
|
||||
let af = match &sh.autofilter {
|
||||
Some(a) => a.clone(),
|
||||
@@ -551,6 +615,8 @@ fn custom_ok(v: &Value, text: &str, op: &str, crit: &str) -> bool {
|
||||
let num = gridcraft_core::parse::parse_number_text(crit);
|
||||
let cmp = match (v.as_f64(), num) {
|
||||
(Some(a), Some(b)) => a.partial_cmp(&b),
|
||||
// A numeric ordering comparison only matches numbers; blanks and text never do.
|
||||
(None, Some(_)) if matches!(op, "<" | "<=" | ">" | ">=") => None,
|
||||
_ => Some(gridcraft_core::compare_text(text, crit)),
|
||||
};
|
||||
let wild = || super::edit::wildcard(&text.to_lowercase(), &crit.to_lowercase());
|
||||
@@ -581,6 +647,11 @@ fn custom_ok(v: &Value, text: &str, op: &str, crit: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// The filter's "(Blanks)" group: truly empty cells and zero-length text such as a `=""` result.
|
||||
fn filter_blank(v: &Value) -> bool {
|
||||
v.is_empty() || matches!(v, Value::Text(t) if t.is_empty())
|
||||
}
|
||||
|
||||
/// Hides rows of the autofilter range that fail any criterion.
|
||||
pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
|
||||
let Some(sh) = wb.sheet(sheet) else { return };
|
||||
@@ -614,7 +685,7 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
|
||||
let text = crate::display::cell_text(wb, sh, c);
|
||||
let ok = match crit {
|
||||
FilterCriterion::Values { values, blanks } => {
|
||||
if v.is_empty() {
|
||||
if filter_blank(&v) {
|
||||
*blanks
|
||||
} else {
|
||||
values.iter().any(|x| x.eq_ignore_ascii_case(&text) || x.eq_ignore_ascii_case(&v.display()))
|
||||
@@ -701,10 +772,11 @@ pub fn filter_values(wb: &Workbook, sheet: usize, col: u32) -> Vec<(String, bool
|
||||
for row in af.range.start.row + 1..=af.range.end.row.min(af.range.start.row + 1_000_000) {
|
||||
let c = CellRef::new(row, col);
|
||||
let v = sh.value(c);
|
||||
let t = if v.is_empty() { "(Blanks)".to_string() } else { crate::display::cell_text(wb, sh, c) };
|
||||
let blank = filter_blank(&v);
|
||||
let t = if blank { "(Blanks)".to_string() } else { crate::display::cell_text(wb, sh, c) };
|
||||
let on = match selected {
|
||||
Some(FilterCriterion::Values { values, blanks }) => {
|
||||
if v.is_empty() {
|
||||
if blank {
|
||||
*blanks
|
||||
} else {
|
||||
values.iter().any(|x| x.eq_ignore_ascii_case(&t))
|
||||
@@ -712,7 +784,8 @@ pub fn filter_values(wb: &Workbook, sheet: usize, col: u32) -> Vec<(String, bool
|
||||
}
|
||||
_ => true,
|
||||
};
|
||||
seen.entry((sort_key(&v), t)).or_insert(on);
|
||||
let key = if blank { sort_key(&Value::Empty) } else { sort_key(&v) };
|
||||
seen.entry((key, t)).or_insert(on);
|
||||
}
|
||||
seen.into_iter().map(|((_, t), on)| (t, on)).collect()
|
||||
}
|
||||
@@ -736,9 +809,9 @@ fn sort_key(v: &Value) -> (u8, i64, String) {
|
||||
// ---------------------------------------------------------------- tools
|
||||
|
||||
fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let r = data_range(s, p)?;
|
||||
let (sheet, r) = data_target(s, p, "data.removeDuplicates")?;
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
// Rows and columns past the data are blank: only the part with data is compared (whole
|
||||
// columns compared a million blank rows and reported them as removed duplicates).
|
||||
let r = sh.used_range().and_then(|u| r.intersection(&u)).unwrap_or(RangeRef::cell(r.start));
|
||||
@@ -748,7 +821,12 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
.and_then(Json::as_array)
|
||||
.map(|a| a.iter().filter_map(|c| col_param(Some(c), r.start.col)).collect())
|
||||
.unwrap_or_else(|| (r.start.col..=r.end.col).collect());
|
||||
let sheet = d.wb.active_sheet;
|
||||
// With no column to compare, every row key is empty: the first row would survive and every
|
||||
// other row would be deleted as a duplicate. A column outside the range compares data the
|
||||
// user didn't select. Excel refuses both.
|
||||
if cols.is_empty() || cols.iter().any(|c| !(r.start.col..=r.end.col).contains(c)) {
|
||||
return Err(bad("data.removeDuplicates", "`columns` must name at least one column of the range"));
|
||||
}
|
||||
let start = r.start.row + header as u32;
|
||||
let mut removed = 0;
|
||||
edit(s, |cx| {
|
||||
@@ -774,6 +852,24 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
.filter_map(move |col| sh.cell_pictures.get(&CellRef::new(*row, col)).map(|p| (CellRef::new(start + i as u32, col), p.clone())))
|
||||
})
|
||||
.collect();
|
||||
// Rows a filter keeps are compacted to `start + i`, so the notes and hyperlinks that
|
||||
// describe them move with them; the ones on removed rows are dropped with the row.
|
||||
let notes: Vec<_> = keep
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, row)| {
|
||||
(r.start.col..=r.end.col)
|
||||
.filter_map(move |col| sh.comments.get(&CellRef::new(*row, col)).map(|cm| (CellRef::new(start + i as u32, col), cm.clone())))
|
||||
})
|
||||
.collect();
|
||||
let links: Vec<_> = keep
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, row)| {
|
||||
(r.start.col..=r.end.col)
|
||||
.filter_map(move |col| sh.hyperlinks.get(&CellRef::new(*row, col)).map(|h| (CellRef::new(start + i as u32, col), h.clone())))
|
||||
})
|
||||
.collect();
|
||||
let rows: Vec<Vec<Option<Cell>>> =
|
||||
keep.iter().map(|row| (r.start.col..=r.end.col).map(|c| sh.cell(CellRef::new(*row, c)).cloned()).collect()).collect();
|
||||
let shm = cx.sheet_mut(sheet)?;
|
||||
@@ -782,6 +878,13 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
shm.remove_cell(CellRef::new(row, c));
|
||||
}
|
||||
}
|
||||
for row in start..=r.end.row {
|
||||
for c in r.start.col..=r.end.col {
|
||||
let at = CellRef::new(row, c);
|
||||
shm.comments.remove(&at);
|
||||
shm.hyperlinks.remove(&at);
|
||||
}
|
||||
}
|
||||
for (i, cells) in rows.into_iter().enumerate() {
|
||||
for (j, cell) in cells.into_iter().enumerate() {
|
||||
if let Some(cell) = cell {
|
||||
@@ -790,6 +893,12 @@ fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
shm.cell_pictures.extend(pictures);
|
||||
for (at, cm) in notes {
|
||||
shm.comments.insert(at, cm);
|
||||
}
|
||||
for (at, h) in links {
|
||||
shm.hyperlinks.insert(at, h);
|
||||
}
|
||||
for c in RangeRef::new(CellRef::new(start, r.start.col), r.end).iter() {
|
||||
cx.changed.push((sheet, c));
|
||||
}
|
||||
@@ -824,7 +933,10 @@ fn text_to_columns(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Some(w) => {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut cuts = vec![0];
|
||||
cuts.extend(w.iter().copied().filter(|x| *x < chars.len()));
|
||||
let mut breaks: Vec<usize> = w.iter().copied().filter(|x| *x > 0 && *x < chars.len()).collect();
|
||||
breaks.sort_unstable();
|
||||
breaks.dedup();
|
||||
cuts.extend(breaks);
|
||||
cuts.push(chars.len());
|
||||
cuts.windows(2)
|
||||
.map(|p| chars.get(p[0]..p[1]).map(|s| s.iter().collect::<String>().trim().to_string()).unwrap_or_default())
|
||||
@@ -949,13 +1061,43 @@ fn validation(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
"time" => ValidationKind::Time,
|
||||
"textLength" => ValidationKind::TextLength,
|
||||
"custom" => ValidationKind::Custom,
|
||||
_ => ValidationKind::Any,
|
||||
"any" => ValidationKind::Any,
|
||||
// An unknown type used to give an "any value" rule, saved as `type="none"`: no validation.
|
||||
other => return Err(bad("data.validation", format!("unknown type `{other}` (any, whole, decimal, list, date, time, textLength, custom)"))),
|
||||
};
|
||||
if kind == ValidationKind::List && str_param(p, "formula1").is_none_or(|f| f.trim().trim_start_matches('=').is_empty()) {
|
||||
return Err(bad("data.validation", "a list validation needs `formula1`"));
|
||||
}
|
||||
const OPERATORS: [&str; 12] = [
|
||||
"between",
|
||||
"notBetween",
|
||||
"equal",
|
||||
"notEqual",
|
||||
"greater",
|
||||
"greaterThan",
|
||||
"less",
|
||||
"lessThan",
|
||||
"greaterOrEqual",
|
||||
"greaterThanOrEqual",
|
||||
"lessOrEqual",
|
||||
"lessThanOrEqual",
|
||||
];
|
||||
if let Some(op) = str_param(p, "operator").filter(|op| !OPERATORS.contains(op)) {
|
||||
return Err(bad("data.validation", format!("unknown operator `{op}` ({})", OPERATORS.join(", "))));
|
||||
}
|
||||
let error_style = match str_param(p, "errorStyle") {
|
||||
None | Some("stop") => ErrorStyle::Stop,
|
||||
Some("warning") => ErrorStyle::Warning,
|
||||
Some("information") => ErrorStyle::Information,
|
||||
Some(other) => return Err(bad("data.validation", format!("unknown errorStyle `{other}` (stop, warning, information)"))),
|
||||
};
|
||||
let f1 = str_param(p, "formula1").unwrap_or("");
|
||||
let f1 = if kind == ValidationKind::List { list_source(f1) } else { f1.trim_start_matches('=').to_string() };
|
||||
let dv = Validation {
|
||||
ranges: ranges.clone(),
|
||||
kind,
|
||||
op: op_param(str_param(p, "operator")),
|
||||
f1: str_param(p, "formula1").unwrap_or("").trim_start_matches('=').to_string(),
|
||||
f1,
|
||||
f2: str_param(p, "formula2").map(|f| f.trim_start_matches('=').to_string()),
|
||||
allow_blank: bool_param(p, "ignoreBlank").unwrap_or(true),
|
||||
in_cell_dropdown: bool_param(p, "dropdown").unwrap_or(true),
|
||||
@@ -964,11 +1106,7 @@ fn validation(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
show_input: true,
|
||||
error_title: str_param(p, "errorTitle").unwrap_or("").into(),
|
||||
error_message: str_param(p, "errorMessage").unwrap_or("").into(),
|
||||
error_style: match str_param(p, "errorStyle") {
|
||||
Some("warning") => ErrorStyle::Warning,
|
||||
Some("information") => ErrorStyle::Information,
|
||||
_ => ErrorStyle::Stop,
|
||||
},
|
||||
error_style,
|
||||
show_error: true,
|
||||
};
|
||||
edit(s, |cx| {
|
||||
@@ -979,14 +1117,36 @@ fn validation(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a list rule's source is a literal list (`"a,b"`, or `a,b` typed without quotes) rather
|
||||
/// than a reference or a name to evaluate.
|
||||
fn is_literal_list(f: &str) -> bool {
|
||||
f.starts_with('"')
|
||||
|| (!f.contains('!')
|
||||
&& gridcraft_formula::parse(f).map(|e| !matches!(e, gridcraft_formula::Expr::Ref(_) | gridcraft_formula::Expr::Name(_))).unwrap_or(true))
|
||||
}
|
||||
|
||||
/// A list rule's source as a file stores it. A literal list typed without quotes, as in Excel's
|
||||
/// Source box (`Open,In progress,Closed`), becomes the quoted string Excel saves
|
||||
/// (`"Open,In progress,Closed"`, inner quotes doubled); unquoted, the file would read it as a
|
||||
/// formula. A formula (`=…`), a reference, a name or an already quoted list is kept as before.
|
||||
fn list_source(f: &str) -> String {
|
||||
let t = f.trim();
|
||||
if t.is_empty() || t.starts_with('=') || t.starts_with('"') || !is_literal_list(t) {
|
||||
return f.trim_start_matches('=').to_string();
|
||||
}
|
||||
format!("\"{}\"", t.replace('"', "\"\""))
|
||||
}
|
||||
|
||||
/// List entries of a list validation (literal or from a range).
|
||||
pub fn list_items(wb: &Workbook, sheet: usize, dv: &Validation) -> Vec<String> {
|
||||
let f = dv.f1.trim();
|
||||
if f.starts_with('"')
|
||||
|| (!f.contains('!')
|
||||
&& gridcraft_formula::parse(f).map(|e| !matches!(e, gridcraft_formula::Expr::Ref(_) | gridcraft_formula::Expr::Name(_))).unwrap_or(true))
|
||||
{
|
||||
return f.trim_matches('"').split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect();
|
||||
if is_literal_list(f) {
|
||||
// A quoted list is a string literal: `""` inside stands for one `"`.
|
||||
let items = match f.strip_prefix('"').and_then(|x| x.strip_suffix('"')) {
|
||||
Some(body) => body.replace("\"\"", "\""),
|
||||
None => f.trim_matches('"').to_string(),
|
||||
};
|
||||
return items.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect();
|
||||
}
|
||||
let at = dv.ranges.first().map(|r| r.start).unwrap_or_default();
|
||||
match gridcraft_calc::evaluate(wb, sheet, at, f) {
|
||||
@@ -1127,10 +1287,10 @@ fn circle_invalid(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
// ---------------------------------------------------------------- outline
|
||||
|
||||
fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> {
|
||||
if let Some(r) = str_param(p, "rows").and_then(RangeRef::parse) {
|
||||
if let Some(r) = str_param(p, "rows").and_then(parse_lines) {
|
||||
return Ok((true, r.start.row, r.end.row));
|
||||
}
|
||||
if let Some(r) = str_param(p, "cols").and_then(RangeRef::parse) {
|
||||
if let Some(r) = str_param(p, "cols").and_then(parse_lines) {
|
||||
return Ok((false, r.start.col, r.end.col));
|
||||
}
|
||||
let r = s.doc()?.selection.current();
|
||||
@@ -1143,9 +1303,13 @@ fn outline_lines(s: &Session, p: &Json) -> Result<(bool, u32, u32)> {
|
||||
}
|
||||
|
||||
fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
|
||||
let si = target_sheet(s, p)?;
|
||||
// The selection belongs to the active sheet, so another sheet needs its lines.
|
||||
if si != s.doc()?.wb.active_sheet && p.get("rows").is_none() && p.get("cols").is_none() {
|
||||
return Err(bad(if on { "data.group" } else { "data.ungroup" }, "missing `rows` or `cols`: the selection is on the active sheet"));
|
||||
}
|
||||
let (rows, a, b) = outline_lines(s, p)?;
|
||||
edit(s, |cx| {
|
||||
let si = cx.sheet_index();
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
let map = if rows { &mut sh.rows } else { &mut sh.cols };
|
||||
let b = b.min(a.saturating_add(1_048_575));
|
||||
@@ -1199,7 +1363,7 @@ fn detail(s: &mut Session, p: &Json, hide: bool) -> Result<Json> {
|
||||
.collect();
|
||||
for i in g.start..=g.end {
|
||||
if let Some(e) = map.get_mut(&i) {
|
||||
e.hidden = hide || inner.iter().any(|o| o.contains(i));
|
||||
set_hidden(e, hide || inner.iter().any(|o| o.contains(i)));
|
||||
}
|
||||
}
|
||||
if hide {
|
||||
@@ -1213,6 +1377,16 @@ fn detail(s: &mut Session, p: &Json, hide: bool) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Hides or shows an outline line. A line shown with a zero size (files that hide columns as
|
||||
/// `width="0" hidden="1"`) gets the default size back, as Unhide does; otherwise + would mark the
|
||||
/// group expanded and leave it invisible (#1714).
|
||||
fn set_hidden(e: &mut LineInfo, hidden: bool) {
|
||||
e.hidden = hidden;
|
||||
if !hidden && e.size == Some(0.0) {
|
||||
e.size = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The outline's level buttons: show rows / columns down to outline level `n` (1 = only the
|
||||
/// top-level summaries); deeper detail is hidden and its summary lines marked collapsed.
|
||||
fn show_levels(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
@@ -1231,7 +1405,7 @@ fn show_levels(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let Some(n) = n else { continue };
|
||||
let groups = outline::groups(map);
|
||||
for e in map.values_mut().filter(|e| e.outline > 0) {
|
||||
e.hidden = e.outline >= n;
|
||||
set_hidden(e, e.outline >= n);
|
||||
}
|
||||
// Inner groups come first, so a summary line shared with the group around it ends up
|
||||
// as that outer group says.
|
||||
@@ -1245,7 +1419,8 @@ fn show_levels(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let r = data_range(s, p)?;
|
||||
let (sheet, r) = data_target(s, p, "data.subtotal")?;
|
||||
let header = bool_param(p, "header").unwrap_or(true);
|
||||
let group_col = col_param(p.get("groupBy"), r.start.col).unwrap_or(r.start.col);
|
||||
let func = str_param(p, "function").unwrap_or("sum");
|
||||
let code = match func {
|
||||
@@ -1269,13 +1444,13 @@ fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
"product" => "Product",
|
||||
_ => "Total",
|
||||
};
|
||||
let sheet = s.doc()?.wb.active_sheet;
|
||||
// Group boundaries (header in the first row).
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
let mut groups: Vec<(u32, u32, String)> = Vec::new();
|
||||
let mut start = r.start.row + 1;
|
||||
for row in r.start.row + 1..=r.end.row + 1 {
|
||||
let first = r.start.row + u32::from(header);
|
||||
let mut start = first;
|
||||
for row in first..=r.end.row + 1 {
|
||||
let cur = sh.value(CellRef::new(row, group_col)).display();
|
||||
let prev = sh.value(CellRef::new(start, group_col)).display();
|
||||
if row > r.end.row || cur != prev {
|
||||
@@ -1286,25 +1461,28 @@ fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
// Insert a total row after each group, bottom-up so indices stay valid.
|
||||
let mut inserted = 0;
|
||||
for (g0, g1, name) in groups.iter().rev() {
|
||||
s.execute("home.insertRows", json!({"rows": format!("{}:{}", g1 + 2, g1 + 2)}))?;
|
||||
s.execute("home.insertRows", json!({"sheet": sheet, "rows": format!("{}:{}", g1 + 2, g1 + 2)}))?;
|
||||
let row = g1 + 1;
|
||||
let lbl = CellRef::new(row, group_col).a1();
|
||||
s.execute("cell.set", json!({"cell": lbl, "input": format!("{name} {label}")}))?;
|
||||
s.execute("cell.set", json!({"sheet": sheet, "cell": lbl, "input": format!("{name} {label}")}))?;
|
||||
for c in &cols {
|
||||
let range = RangeRef::new(CellRef::new(*g0, *c), CellRef::new(*g1, *c)).a1();
|
||||
s.execute("cell.set", json!({"cell": CellRef::new(row, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?;
|
||||
s.execute("cell.set", json!({"sheet": sheet, "cell": CellRef::new(row, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?;
|
||||
}
|
||||
s.execute("home.bold", json!({"range": RangeRef::new(CellRef::new(row, r.start.col), CellRef::new(row, r.end.col)).a1(), "on": true}))?;
|
||||
s.execute("data.group", json!({"rows": format!("{}:{}", g0 + 1, g1 + 1)}))?;
|
||||
s.execute(
|
||||
"home.bold",
|
||||
json!({"sheet": sheet, "range": RangeRef::new(CellRef::new(row, r.start.col), CellRef::new(row, r.end.col)).a1(), "on": true}),
|
||||
)?;
|
||||
s.execute("data.group", json!({"sheet": sheet, "rows": format!("{}:{}", g0 + 1, g1 + 1)}))?;
|
||||
inserted += 1;
|
||||
}
|
||||
let last = r.end.row + inserted + 1;
|
||||
let lbl = CellRef::new(last, group_col).a1();
|
||||
s.execute("home.insertRows", json!({"rows": format!("{}:{}", last + 1, last + 1)}))?;
|
||||
s.execute("cell.set", json!({"cell": lbl, "input": format!("Grand {label}")}))?;
|
||||
s.execute("home.insertRows", json!({"sheet": sheet, "rows": format!("{}:{}", last + 1, last + 1)}))?;
|
||||
s.execute("cell.set", json!({"sheet": sheet, "cell": lbl, "input": format!("Grand {label}")}))?;
|
||||
for c in &cols {
|
||||
let range = RangeRef::new(CellRef::new(r.start.row + 1, *c), CellRef::new(last - 1, *c)).a1();
|
||||
s.execute("cell.set", json!({"cell": CellRef::new(last, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?;
|
||||
let range = RangeRef::new(CellRef::new(first, *c), CellRef::new(last - 1, *c)).a1();
|
||||
s.execute("cell.set", json!({"sheet": sheet, "cell": CellRef::new(last, *c).a1(), "input": format!("=SUBTOTAL({code},{range})")}))?;
|
||||
}
|
||||
Ok(json!({"groups": inserted}))
|
||||
}
|
||||
@@ -1313,6 +1491,37 @@ fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn filter_blanks_include_empty_text_and_formula_results() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["id", "f"], ["e", null], ["ef", "=\"\""], ["sp", " "], ["z", 0]]})).unwrap();
|
||||
s.execute("data.filter", json!({"range": "A1:B5"})).unwrap();
|
||||
let r = s.execute("data.filterBy", json!({"column": "B", "values": [], "blanks": true})).unwrap();
|
||||
assert_eq!(r["hiddenRows"], json!(2));
|
||||
let vals = filter_values(&s.doc().unwrap().wb, 0, 1);
|
||||
// The `=""` result joins (Blanks) instead of showing as its own empty entry.
|
||||
assert!(!vals.iter().any(|(t, _)| t.is_empty()), "{vals:?}");
|
||||
let blanks: Vec<_> = vals.into_iter().filter(|(t, _)| t == "(Blanks)").collect();
|
||||
assert_eq!(blanks, vec![("(Blanks)".to_string(), true)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subtotal_honours_header_false() {
|
||||
// #131: with header:false the first data row was treated as a header and left out of its
|
||||
// group's subtotal and the grand total.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["East", 10], ["East", 20], ["West", 30], ["West", 40]]})).unwrap();
|
||||
s.execute("data.subtotal", json!({"range": "A1:B4", "groupBy": "A", "function": "sum", "columns": ["B"], "header": false})).unwrap();
|
||||
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
|
||||
assert_eq!(get(&mut s, "A1")["value"], json!("East"));
|
||||
assert_eq!(get(&mut s, "A3")["text"], json!("East Total"));
|
||||
assert_eq!(get(&mut s, "B3")["value"], json!(30.0), "East's subtotal counts both East rows");
|
||||
assert_eq!(get(&mut s, "A7")["text"], json!("Grand Total"));
|
||||
assert_eq!(get(&mut s, "B7")["value"], json!(100.0), "the grand total counts every data row");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_by_huge_numeric_column_is_rejected_not_overflowed() {
|
||||
let mut s = Session::new();
|
||||
@@ -1345,6 +1554,17 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_validation_without_formula1_is_an_error() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
assert!(s.execute("data.validation", json!({"range": "A1:A3", "type": "list", "source": "\"a,b,c\""})).is_err());
|
||||
assert!(s.execute("data.validation", json!({"range": "A1:A3", "type": "list"})).is_err());
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().validations.is_empty());
|
||||
s.execute("data.validation", json!({"range": "A1:A3", "type": "list", "formula1": "\"a,b,c\""})).unwrap();
|
||||
assert_eq!(s.execute("data.validate", json!({"cell": "A1", "input": "a"})).unwrap()["ok"], json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_validation_edits_keep_the_rest_of_the_old_rule() {
|
||||
let mut s = Session::new();
|
||||
@@ -1367,6 +1587,31 @@ mod tests {
|
||||
assert!(!ok(&mut s, "B1", "150"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn showing_detail_gives_zero_width_columns_their_width_back() {
|
||||
// #1714: templates hide grouped columns as `width="0" hidden="1"`; + must show them.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let hidden = LineInfo { size: Some(0.0), hidden: true, outline: 1, ..LineInfo::default() };
|
||||
let sh = Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
|
||||
for c in 2..=4 {
|
||||
sh.cols.insert(c, hidden);
|
||||
}
|
||||
sh.cols.insert(5, LineInfo { collapsed: true, ..LineInfo::default() });
|
||||
s.execute("data.showDetail", json!({"cols": "C:E", "level": 1})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
for c in 2..=4 {
|
||||
assert_eq!(sh.col_width(c), sh.default_col_width, "column {c}");
|
||||
}
|
||||
// Hiding again hides them; the level buttons show zero-width columns too.
|
||||
s.execute("data.hideDetail", json!({"cols": "C:E", "level": 1})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().col_width(2), 0.0);
|
||||
Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap().cols.insert(3, hidden);
|
||||
s.execute("data.showLevels", json!({"cols": 2})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert_eq!((sh.col_width(2), sh.col_width(3)), (sh.default_col_width, sh.default_col_width));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_an_autofilter_keeps_manually_hidden_and_collapsed_rows() {
|
||||
let mut s = Session::new();
|
||||
@@ -1493,10 +1738,149 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_row_and_column_parameters() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2], [3, 4]]})).unwrap();
|
||||
s.execute("home.insertRows", json!({"rows": "2"})).unwrap();
|
||||
s.execute("home.insertColumns", json!({"cols": "B"})).unwrap();
|
||||
let v = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["value"].clone();
|
||||
assert_eq!(v(&mut s, "C3"), json!(4.0));
|
||||
s.execute("home.hideRows", json!({"rows": "1"})).unwrap();
|
||||
s.execute("data.group", json!({"cols": "C"})).unwrap();
|
||||
assert!(s.execute("home.deleteRows", json!({"rows": "0"})).is_err());
|
||||
s.execute("home.deleteRows", json!({"rows": "2"})).unwrap();
|
||||
s.execute("home.deleteColumns", json!({"cols": "B"})).unwrap();
|
||||
assert_eq!(v(&mut s, "B2"), json!(4.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t2c_fixed_widths_are_positions_in_any_order_without_duplicates() {
|
||||
let cases: [(Json, [&str; 4]); 5] = [
|
||||
(json!([6, 3]), ["abc", "def", "ghij", ""]),
|
||||
(json!([3, 3]), ["abc", "defghij", "", ""]),
|
||||
(json!([0]), ["abcdefghij", "", "", ""]),
|
||||
(json!([20]), ["abcdefghij", "", "", ""]),
|
||||
(json!([7, 0, 3, 7, 10]), ["abc", "defg", "hij", ""]),
|
||||
];
|
||||
for (widths, want) in cases {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "abcdefghij"})).unwrap();
|
||||
s.execute("data.textToColumns", json!({"range": "A1:A1", "fixedWidths": widths})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
let got: Vec<String> = (0..4).map(|c| sh.value(CellRef::new(0, c)).display()).collect();
|
||||
assert_eq!(got, want, "{widths}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split() {
|
||||
assert_eq!(split_delimited("a,b,,c", &[','], false, '"'), ["a", "b", "", "c"]);
|
||||
assert_eq!(split_delimited("a,b,,c", &[','], true, '"'), ["a", "b", "c"]);
|
||||
assert_eq!(split_delimited("\"x,y\",z", &[','], false, '"'), ["x,y", "z"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_duplicates_without_a_valid_column_is_refused() {
|
||||
// `columns: []` (or only unusable names) used to compare nothing and delete every row
|
||||
// but the first.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["a"], ["b"], ["c"]]})).unwrap();
|
||||
for columns in [json!([]), json!(["ZZZ"])] {
|
||||
let err = s.execute("data.removeDuplicates", json!({"range": "A1:A3", "columns": columns})).unwrap_err();
|
||||
assert!(err.to_string().contains("columns"), "{err}");
|
||||
}
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sh.value(CellRef::new(0, 0)), Value::text("a"));
|
||||
assert_eq!(sh.value(CellRef::new(2, 0)), Value::text("c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_list_typed_without_quotes_is_saved_as_a_quoted_list() {
|
||||
// In a file an inline list is a string literal (`"a,b"`); unquoted it reads as a formula.
|
||||
// References, names and formulas stay unquoted, and every rule survives a save and reopen.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "K1:K3", "values": [["Red"], ["Green"], ["Blue"]]})).unwrap();
|
||||
s.execute("formulas.defineName", json!({"name": "Colors", "refersTo": "=Sheet1!$K$1:$K$3"})).unwrap();
|
||||
let rules = [
|
||||
("A1", "Open,In progress,Closed", r#""Open,In progress,Closed""#),
|
||||
("B1", r#""Open,In progress,Closed""#, r#""Open,In progress,Closed""#),
|
||||
("C1", " 1,2,3 ", r#""1,2,3""#),
|
||||
("D1", r#"Say "hi",Bye"#, r#""Say ""hi"",Bye""#),
|
||||
("E1", "=$K$1:$K$3", "$K$1:$K$3"),
|
||||
("F1", "$K$1:$K$3", "$K$1:$K$3"),
|
||||
("G1", "=Colors", "Colors"),
|
||||
("H1", "Sheet1!$K$1:$K$3", "Sheet1!$K$1:$K$3"),
|
||||
("I1", "=OFFSET($K$1,0,0,3,1)", "OFFSET($K$1,0,0,3,1)"),
|
||||
];
|
||||
for (cell, source, _) in rules {
|
||||
s.execute("data.validation", json!({"range": cell, "type": "list", "formula1": source})).unwrap();
|
||||
}
|
||||
let accepted = [
|
||||
("A1", "In progress"),
|
||||
("B1", "Closed"),
|
||||
("C1", "2"),
|
||||
("D1", r#"Say "hi""#),
|
||||
("E1", "Green"),
|
||||
("F1", "Blue"),
|
||||
("G1", "Red"),
|
||||
("H1", "Green"),
|
||||
];
|
||||
let check = |wb: &Workbook| {
|
||||
for (cell, input) in accepted {
|
||||
let at = CellRef::parse(cell).unwrap();
|
||||
assert!(check_validation(wb, 0, at, input).is_none(), "{cell} refuses {input}");
|
||||
assert!(check_validation(wb, 0, at, "Pending").is_some(), "{cell} accepts Pending");
|
||||
}
|
||||
};
|
||||
let wb = s.doc().unwrap().wb.clone();
|
||||
check(&wb);
|
||||
|
||||
let sheet_xml = |wb: &Workbook| {
|
||||
let bytes = gridcraft_xlsx::write_xlsx(wb).unwrap();
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("xl/worksheets/sheet1.xml").unwrap(), &mut xml).unwrap();
|
||||
xml
|
||||
};
|
||||
let xml = sheet_xml(&wb);
|
||||
for (cell, _, saved) in rules {
|
||||
assert!(xml.contains(&format!(r#"sqref="{cell}"><formula1>{saved}</formula1>"#)), "{cell}: {saved} missing from {xml}");
|
||||
}
|
||||
let (reopened, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(&wb).unwrap()).unwrap();
|
||||
check(&reopened);
|
||||
assert_eq!(sheet_xml(&reopened), xml, "a second save writes the same rules");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rule_with_an_unknown_type_operator_or_error_style_is_refused() {
|
||||
// A typo used to give a rule that validates nothing (`type="none"`) or the wrong check,
|
||||
// with no error.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
for params in [
|
||||
json!({"range": "A1", "type": "lst", "formula1": "\"a,b\""}),
|
||||
json!({"range": "A1", "type": "whole", "operator": "greaterthan", "formula1": "1"}),
|
||||
json!({"range": "A1", "type": "whole", "operator": ">", "formula1": "1"}),
|
||||
json!({"range": "A1", "type": "list", "formula1": "\"a,b\"", "errorStyle": "warn"}),
|
||||
] {
|
||||
let e = s.execute("data.validation", params.clone()).unwrap_err().to_string();
|
||||
assert!(e.contains("unknown"), "{params}: {e}");
|
||||
}
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().validations.is_empty());
|
||||
for params in [
|
||||
json!({"range": "A1", "type": "any"}),
|
||||
json!({"range": "A2", "type": "whole", "operator": "greaterThan", "formula1": "1"}),
|
||||
json!({"range": "A3", "type": "list", "formula1": "\"a,b\"", "errorStyle": "warning"}),
|
||||
] {
|
||||
s.execute("data.validation", params).unwrap();
|
||||
}
|
||||
let sh = s.doc().unwrap().wb.active().unwrap().clone();
|
||||
assert_eq!(sh.validations.len(), 3);
|
||||
assert!(check_validation(&s.doc().unwrap().wb, 0, CellRef::parse("A2").unwrap(), "0").is_some());
|
||||
}
|
||||
}
|
||||
|
||||
+305
-35
@@ -16,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Values",
|
||||
[],
|
||||
None,
|
||||
"{range?: \"A1\", values: [[...]] (rows of numbers/strings/bools/null; strings starting with = are formulas)}",
|
||||
"{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)}",
|
||||
has_doc,
|
||||
range_set_values
|
||||
),
|
||||
@@ -79,7 +79,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"AutoFill",
|
||||
[],
|
||||
None,
|
||||
"{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies)",
|
||||
"{source: \"A1:A2\", target: \"A1:A10\", mode?: series|alternate|copy|formats|values|extend}: the fill-handle drag (alternate = Ctrl+drag: a lone number counts up, any other series copies; values = Fill Without Formatting: the series, targets keep their formats; extend = Fill Series: the series, and a lone number counts up)",
|
||||
has_doc,
|
||||
auto_fill
|
||||
),
|
||||
@@ -138,18 +138,7 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri
|
||||
&& input.chars().nth(1).is_some_and(|c| c.is_ascii_alphabetic() || c == '('));
|
||||
if is_formula {
|
||||
let body = input.strip_prefix('=').unwrap_or(input);
|
||||
// Excel closes missing parentheses for you.
|
||||
let mut text = body.to_string();
|
||||
let mut parsed = gridcraft_formula::parse(&text);
|
||||
for _ in 0..8 {
|
||||
if parsed.is_ok() {
|
||||
break;
|
||||
}
|
||||
text.push(')');
|
||||
parsed = gridcraft_formula::parse(&text);
|
||||
}
|
||||
let expr = parsed.map_err(|e| EngineError::Other(format!("There's a problem with this formula: {e}")))?;
|
||||
let expr = wb.name_call_case(expr);
|
||||
let expr = wb.name_call_case(parse_typed_formula(body)?);
|
||||
check_arg_counts(&expr)?;
|
||||
let mut cell = Cell::formula(Formula::from_expr(expr));
|
||||
cell.style = style;
|
||||
@@ -176,7 +165,17 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri
|
||||
}
|
||||
return Ok(Some(cell));
|
||||
}
|
||||
let parsed = gridcraft_core::parse::parse_input(input, wb.date_system);
|
||||
let mut parsed = gridcraft_core::parse::parse_input(input, wb.date_system);
|
||||
// Like Excel's automatic percent entry: a bare number of 1 or more typed into a cell already
|
||||
// formatted as a percentage is a percent (3 -> 3%); smaller numbers are taken as fractions.
|
||||
if parsed.format.is_none()
|
||||
&& !input.contains('%')
|
||||
&& wb.styles.get(style).num_fmt.as_str().ends_with('%')
|
||||
&& let Value::Number(n) = parsed.value
|
||||
&& n.abs() >= 1.0
|
||||
{
|
||||
parsed.value = Value::Number(n / 100.0);
|
||||
}
|
||||
let mut style = style;
|
||||
if let Some(code) = parsed.format {
|
||||
let cur = wb.styles.get(style).num_fmt.as_str().to_string();
|
||||
@@ -191,11 +190,19 @@ pub(crate) fn input_to_cell(input: &str, sheet: usize, at: CellRef, wb: &mut gri
|
||||
Ok(Some(Cell { value: parsed.value, formula: None, style }))
|
||||
}
|
||||
|
||||
/// Parses a formula as it is typed (without the `=`). Excel closes missing parentheses for you,
|
||||
/// and refuses a formula that still doesn't parse.
|
||||
pub(crate) fn parse_typed_formula(body: &str) -> Result<gridcraft_formula::Expr> {
|
||||
// Excel closes missing parentheses for you.
|
||||
let parsed = gridcraft_formula::parse_closing_parens(body, gridcraft_formula::parse);
|
||||
parsed.map_err(|e| EngineError::Other(format!("There's a problem with this formula: {e}")))
|
||||
}
|
||||
|
||||
/// Like Excel, a formula that calls a built-in function with too few or too many arguments is
|
||||
/// refused when it's entered, rather than stored to show `#VALUE!`. Functions the registry doesn't
|
||||
/// hold (special forms like LET, LAMBDA, OFFSET, INDIRECT, SUBTOTAL), defined LAMBDA names and
|
||||
/// unknown functions are skipped.
|
||||
fn check_arg_counts(expr: &gridcraft_formula::Expr) -> Result<()> {
|
||||
pub(crate) fn check_arg_counts(expr: &gridcraft_formula::Expr) -> Result<()> {
|
||||
let mut bad = None;
|
||||
expr.walk(&mut |e| {
|
||||
if bad.is_none()
|
||||
@@ -292,6 +299,15 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let start = target_range(s, p)?.start;
|
||||
let Some(rows) = p.get("values").and_then(Json::as_array) else { return Err(bad("range.setValues", "missing `values` (array of rows)")) };
|
||||
// A string is run through typed input by default, which reads it with en-US rules: a caller
|
||||
// sending "05.04.2026" from a dd.mm.yyyy export got 4 May, and "2.500" became 2.5, in silence
|
||||
// (#2255). `parse: false` stores a string as the text it is; numbers, booleans and formulas
|
||||
// are unaffected either way.
|
||||
let parse = match p.get("parse") {
|
||||
None | Some(Json::Null) => true,
|
||||
Some(Json::Bool(b)) => *b,
|
||||
Some(other) => return Err(bad("range.setValues", format!("parse {other}: true or false"))),
|
||||
};
|
||||
if rows.len() > MAX_ROWS as usize {
|
||||
return Err(bad("range.setValues", "too many rows"));
|
||||
}
|
||||
@@ -314,6 +330,17 @@ fn range_set_values(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
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);
|
||||
}
|
||||
Some(Cell { value: Value::Text(text.as_str().into()), formula: None, style })
|
||||
}
|
||||
_ => input_to_cell(&input, sheet, at, &mut cx.wb)?,
|
||||
};
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
@@ -338,20 +365,31 @@ fn range_fill(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let input = str_param(p, "input").unwrap_or("").to_string();
|
||||
let origin = s.doc()?.selection.active;
|
||||
edit(s, |cx| {
|
||||
let base = input_to_cell(&input, sheet, origin, &mut cx.wb)?;
|
||||
// Each cell gets what typing the input there gives: the automatic number format where
|
||||
// it's General, its own format otherwise, text where it's Text. That depends only on
|
||||
// the cell's style, so the input is parsed once per style.
|
||||
let mut by_style: std::collections::HashMap<StyleId, Option<Cell>> = std::collections::HashMap::new();
|
||||
for r in &ranges {
|
||||
if r.count() > 2_000_000 {
|
||||
return Err(bad("range.fill", "range too large"));
|
||||
}
|
||||
for at in r.iter() {
|
||||
let mut cell = base.clone().unwrap_or_default();
|
||||
let style = cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default();
|
||||
let base = match by_style.get(&style) {
|
||||
Some(c) => c.clone(),
|
||||
None => {
|
||||
let c = input_to_cell(&input, sheet, at, &mut cx.wb)?;
|
||||
by_style.insert(style, c.clone());
|
||||
c
|
||||
}
|
||||
};
|
||||
let mut cell = base.unwrap_or_default();
|
||||
if let Some(f) = &cell.formula
|
||||
&& let Some(e) = f.expr()
|
||||
{
|
||||
let shifted = gridcraft_formula::adjust::shift_relative(e, at.row as i64 - origin.row as i64, at.col as i64 - origin.col as i64);
|
||||
cell.formula = Some(std::sync::Arc::new(Formula::from_expr(shifted)));
|
||||
}
|
||||
cell.style = cx.wb.sheet(sheet).and_then(|sh| sh.cell(at)).map(|c| c.style).unwrap_or(cell.style);
|
||||
cx.sheet_mut(sheet)?.set_cell(at, cell);
|
||||
cx.touch(sheet, at);
|
||||
}
|
||||
@@ -362,6 +400,12 @@ fn range_fill(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
// ---------------------------------------------------------------- selection
|
||||
|
||||
/// Whether the cell may be selected: a protected sheet can forbid selecting locked and/or unlocked cells.
|
||||
fn cell_selectable(wb: &gridcraft_model::Workbook, sheet: &gridcraft_model::Sheet, c: CellRef) -> bool {
|
||||
let Some(p) = sheet.protection.as_ref() else { return true };
|
||||
if wb.styles.get(sheet.style_id(c)).protection.locked { p.select_locked } else { p.select_unlocked }
|
||||
}
|
||||
|
||||
fn selection_set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if p.get("sheet").is_some() {
|
||||
let i = target_sheet(s, p)?;
|
||||
@@ -379,6 +423,9 @@ fn selection_set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let mut sel = crate::Selection { active, anchor: ranges.last().map(|r| r.start).unwrap_or(active), ranges };
|
||||
if let Some(sh) = d.wb.active() {
|
||||
sel.expand_merges(sh);
|
||||
if !cell_selectable(&d.wb, sh, sel.active) {
|
||||
return Err(EngineError::Other("The cell you are trying to change is on a protected sheet that does not allow selecting it.".into()));
|
||||
}
|
||||
}
|
||||
d.selection = sel;
|
||||
Ok(json!({"selection": d.selection.a1(), "active": d.selection.active.a1()}))
|
||||
@@ -447,6 +494,29 @@ fn selection_move(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
{
|
||||
to = m.start;
|
||||
}
|
||||
if !extend && !cell_selectable(&d.wb, sheet, to) {
|
||||
// Step on to the next selectable cell in the same direction; stay put if there is none.
|
||||
let (sr, sc) = (dr.signum(), dc.signum());
|
||||
let mut t = to;
|
||||
let mut found = false;
|
||||
if !jmp && (sr, sc) != (0, 0) {
|
||||
for _ in 0..10_000 {
|
||||
let n = t.offset_clamped(sr, sc);
|
||||
if n == t {
|
||||
break;
|
||||
}
|
||||
t = n;
|
||||
if !sheet.is_row_hidden(t.row) && !sheet.is_col_hidden(t.col) && cell_selectable(&d.wb, sheet, t) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return Ok(json!({"selection": sel.a1(), "active": sel.active.a1()}));
|
||||
}
|
||||
to = t;
|
||||
}
|
||||
if extend {
|
||||
sel.extend_to(to);
|
||||
sel.expand_merges(sheet);
|
||||
@@ -514,7 +584,7 @@ pub(super) fn go_to(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
} else if let Some((si, ti)) = d.wb.table(reference) {
|
||||
// As in Excel, a table's name stands for its data, without the header and totals rows.
|
||||
let t = d.wb.sheet(si).and_then(|sh| sh.tables.get(ti)).map(|t| t.data_range().unwrap_or(t.range).a1()).unwrap_or_default();
|
||||
format!("'{}'!{}", d.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default(), t)
|
||||
format!("{}!{}", gridcraft_core::quote_sheet(&d.wb.sheet(si).map(|s| s.name.clone()).unwrap_or_default()), t)
|
||||
} else {
|
||||
return Err(EngineError::Other("Reference isn't valid.".into()));
|
||||
}
|
||||
@@ -601,11 +671,13 @@ fn undo(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let now = d.wb.clone();
|
||||
for _ in 0..steps {
|
||||
let Some(e) = d.undo.pop() else { break };
|
||||
let cur = crate::HistoryEntry { label: e.label.clone(), wb: d.wb.clone(), selection: d.selection.clone() };
|
||||
d.redo.push(cur);
|
||||
let cur_wb = d.wb.clone();
|
||||
// Redo goes back to the undone edit's cells, not wherever the user clicked before undoing (#1855).
|
||||
let cur_selection = e.selection.clone();
|
||||
label = e.label.clone();
|
||||
d.wb = e.wb;
|
||||
d.selection = e.selection;
|
||||
crate::push_history(&mut d.redo, label.clone(), cur_wb, &d.wb, cur_selection);
|
||||
}
|
||||
super::extra::keep_watch_list(&now, &mut d.wb);
|
||||
d.calc.rebuild(&d.wb);
|
||||
@@ -620,11 +692,12 @@ fn redo(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let now = d.wb.clone();
|
||||
for _ in 0..steps {
|
||||
let Some(e) = d.redo.pop() else { break };
|
||||
let cur = crate::HistoryEntry { label: e.label.clone(), wb: d.wb.clone(), selection: d.selection.clone() };
|
||||
d.undo.push(cur);
|
||||
let cur_wb = d.wb.clone();
|
||||
let cur_selection = d.selection.clone();
|
||||
label = e.label.clone();
|
||||
d.wb = e.wb;
|
||||
d.selection = e.selection;
|
||||
crate::push_history(&mut d.undo, label.clone(), cur_wb, &d.wb, cur_selection);
|
||||
}
|
||||
super::extra::keep_watch_list(&now, &mut d.wb);
|
||||
d.calc.rebuild(&d.wb);
|
||||
@@ -793,10 +866,11 @@ fn paste_text(s: &mut Session, p: &Json, text: &str) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let at = cell_param_on(s, p, "at", sheet)?.unwrap_or(s.doc()?.selection.current().start);
|
||||
let rows: Vec<Vec<String>> = parse_tsv(text);
|
||||
// The HTML flavour, when the host passed it: the number behind each cell's shown text.
|
||||
let html = str_param(p, "html").map(crate::clip_html::table_cells).unwrap_or_default();
|
||||
let h = rows.len();
|
||||
let w = rows.iter().map(Vec::len).max().unwrap_or(0);
|
||||
// The HTML flavour, when the host passed it: the number behind each cell's shown text. Read
|
||||
// only as far as the text's own shape, so spans in the markup can't expand without bound.
|
||||
let html = str_param(p, "html").map(|html| crate::clip_html::table_cells(html, h, w)).unwrap_or_default();
|
||||
edit(s, |cx| {
|
||||
for (ri, row) in rows.iter().enumerate() {
|
||||
for (ci, v) in row.iter().enumerate() {
|
||||
@@ -936,6 +1010,10 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let src_wb = clip.wb.clone();
|
||||
let Some(src_sheet) = src_wb.sheet(clip.sheet) else { return Err(EngineError::Other("The copied sheet no longer exists.".into())) };
|
||||
let src_sheet_name = src_sheet.name.clone();
|
||||
if cut_move && at.offset((tiles_r * h).saturating_sub(1) as i64, (tiles_c * w).saturating_sub(1) as i64).is_none() {
|
||||
// Check the whole footprint before the source is emptied, so a move that overflows the grid loses nothing.
|
||||
return Err(EngineError::Other("The cut area doesn't fit at this position; nothing was moved.".into()));
|
||||
}
|
||||
edit(s, |cx| {
|
||||
// Styles from another workbook must be re-interned.
|
||||
let mut style_map = std::collections::HashMap::new();
|
||||
@@ -952,6 +1030,13 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let source = cx.sheet_mut(clip.sheet)?;
|
||||
moved_pictures = source.cell_pictures.iter().filter(|(c, _)| src.contains(**c)).map(|(c, p)| (*c, p.clone())).collect();
|
||||
let taken = source.take_cells(src);
|
||||
// Notes, hyperlinks and merged areas sit beside the cells, and a cut takes them
|
||||
// with the block: the destination is written from the clipboard snapshot taken
|
||||
// before the cut, so removing them here loses nothing and leaves the old place
|
||||
// clean instead of leaving one merged block and one note behind.
|
||||
source.comments.retain(|c, _| !src.contains(*c));
|
||||
source.hyperlinks.retain(|c, _| !src.contains(*c));
|
||||
source.merges.retain(|m| !src.contains_range(m));
|
||||
for (c, _) in &taken {
|
||||
cx.touch(clip.sheet, *c);
|
||||
}
|
||||
@@ -999,11 +1084,12 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
// The array range moves with its formula.
|
||||
let (dr, dc) = (dest.row as i64 - sc.row as i64, dest.col as i64 - sc.col as i64);
|
||||
moved.array = f.array.and_then(|a| Some(RangeRef::new(a.start.offset(dr, dc)?, a.end.offset(dr, dc)?)));
|
||||
moved.dynamic = f.dynamic;
|
||||
return Some(std::sync::Arc::new(moved));
|
||||
}
|
||||
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::from_expr(moved)))
|
||||
Some(std::sync::Arc::new(Formula { dynamic: f.dynamic, ..Formula::from_expr(moved) }))
|
||||
};
|
||||
if link {
|
||||
let sheet_prefix = if dest_sheet == clip.sheet && same_doc {
|
||||
@@ -1130,6 +1216,9 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
|
||||
cx.sheet_mut(dest_sheet)?.comments.insert(dest, cm);
|
||||
}
|
||||
if let Some(h) = src_sheet.hyperlinks.get(&sc).cloned() {
|
||||
cx.sheet_mut(dest_sheet)?.hyperlinks.insert(dest, h);
|
||||
}
|
||||
cx.sheet_mut(dest_sheet)?.set_cell(dest, new);
|
||||
}
|
||||
_ => cx.sheet_mut(dest_sheet)?.set_cell(dest, new),
|
||||
@@ -1205,12 +1294,13 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &
|
||||
{
|
||||
let mut nf = Formula::from_expr(new);
|
||||
nf.array = array;
|
||||
nf.dynamic = f.dynamic;
|
||||
cell.formula = Some(std::sync::Arc::new(nf));
|
||||
}
|
||||
}
|
||||
}
|
||||
for n in wb.names.iter_mut() {
|
||||
if let Ok(expr) = gridcraft_formula::parse(&n.formula) {
|
||||
if let Ok(expr) = gridcraft_formula::parse_name(&n.formula) {
|
||||
// Names are workbook-level: unqualified refs don't occur; qualified ones adjust.
|
||||
let new = gridcraft_formula::adjust::adjust(expr, "\u{0}", target, e);
|
||||
n.formula = gridcraft_formula::print(&new);
|
||||
@@ -1246,6 +1336,9 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
|
||||
};
|
||||
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();
|
||||
// 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)));
|
||||
let mut cells = cells;
|
||||
if what == "contents" {
|
||||
for (_, cell) in cells.iter_mut() {
|
||||
@@ -1284,10 +1377,24 @@ fn clear(s: &mut Session, p: &Json, what: &str) -> Result<Json> {
|
||||
sh.hyperlinks.retain(|c, _| !r.contains(*c));
|
||||
}
|
||||
if matches!(what, "all" | "formats") {
|
||||
sh.merges.retain(|m| !r.contains_range(m));
|
||||
}
|
||||
for c in r.iter().take(1_000_000) {
|
||||
cx.changed.push((sheet, c));
|
||||
let mut unmerged = false;
|
||||
sh.merges.retain(|m| {
|
||||
let keep = !r.contains_range(m);
|
||||
unmerged |= !keep;
|
||||
keep
|
||||
});
|
||||
if unmerged {
|
||||
// An empty merge can block a spill without storing any cells in this range.
|
||||
// As in Unmerge, retry blocked formula anchors that could reach the freed area.
|
||||
let reach = RangeRef::new(CellRef::new(0, 0), r.end);
|
||||
let blocked: Vec<_> = sh
|
||||
.cells
|
||||
.iter_range(reach)
|
||||
.filter(|(_, cell)| cell.formula.is_some() && cell.value == Value::Error(gridcraft_core::CellError::Spill))
|
||||
.map(|(c, _)| (sheet, c))
|
||||
.collect();
|
||||
cx.changed.extend(blocked);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Json::Null)
|
||||
@@ -1306,7 +1413,7 @@ fn fill_dir(s: &mut Session, p: &Json, dr: i64, dc: i64) -> Result<Json> {
|
||||
None => return Ok(Json::Null),
|
||||
}
|
||||
}
|
||||
if r.count() > 5_000_000 {
|
||||
if r.count() > crate::fill::MAX_FILL_CELLS {
|
||||
return Err(bad("edit.fill", "range too large"));
|
||||
}
|
||||
// With one row selected, Fill Down copies from the row above (Excel behaviour).
|
||||
@@ -1340,11 +1447,18 @@ fn auto_fill(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
"alternate" => crate::fill::FillMode::Alternate,
|
||||
"formats" => crate::fill::FillMode::Formats,
|
||||
"values" => crate::fill::FillMode::ValuesOnly,
|
||||
"extend" => crate::fill::FillMode::Extend,
|
||||
_ => crate::fill::FillMode::Series,
|
||||
};
|
||||
if target.count() > 5_000_000 || src.count() > 5_000_000 {
|
||||
if target.count() > crate::fill::MAX_FILL_CELLS || src.count() > crate::fill::MAX_FILL_CELLS {
|
||||
return Err(bad("edit.autoFill", "range too large"));
|
||||
}
|
||||
// Excel refuses a target that doesn't hold the source in a corner. Such targets used to fill
|
||||
// every source lane across the whole target extent: billions of cells for whole rows (`2:3`)
|
||||
// into whole columns (`A:A`).
|
||||
if !crate::fill::holds_source_in_corner(src, target) {
|
||||
return Err(bad("edit.autoFill", "the target must contain the source in one of its corners"));
|
||||
}
|
||||
edit(s, |cx| {
|
||||
crate::fill::fill(cx, sheet, src, target, mode)?;
|
||||
*cx.sel = crate::Selection { active: src.start, anchor: src.start, ranges: vec![target.union(&src)] };
|
||||
@@ -1529,10 +1643,24 @@ fn replace(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Ok(json!({"replaced": n}))
|
||||
}
|
||||
|
||||
/// Whether case-folding `s` maps every char to exactly one char of the same UTF-8 length, so
|
||||
/// byte positions in the folded text still line up with the original's character boundaries.
|
||||
fn folds_in_place(s: &str) -> bool {
|
||||
s.chars().all(|c| {
|
||||
let mut folded = c.to_lowercase();
|
||||
let first = folded.next();
|
||||
folded.next().is_none() && first.is_some_and(|l| l.len_utf8() == c.len_utf8())
|
||||
})
|
||||
}
|
||||
|
||||
fn replace_ci(text: &str, what: &str, with: &str) -> String {
|
||||
let lower = text.to_lowercase();
|
||||
let w = what.to_lowercase();
|
||||
if lower.len() != text.len() || w.is_empty() {
|
||||
// Slicing `text` at positions found in `lower` is only sound when folding kept every byte
|
||||
// boundary: a length check alone misses foldings that keep the total length while shifting
|
||||
// boundaries (U+212A KELVIN SIGN shrinks 3→1 bytes, U+0130 grows 2→3), where the tail used
|
||||
// to be dropped. Those fall back to the case-sensitive replace, as length changes already do.
|
||||
if lower.len() != text.len() || w.is_empty() || !folds_in_place(text) || !folds_in_place(what) {
|
||||
return text.replace(what, with);
|
||||
}
|
||||
let mut out = String::new();
|
||||
@@ -1557,6 +1685,63 @@ fn format_painter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let Some(src_sheet) = wb.sheet(sheet) else { return Ok(()) };
|
||||
let (h, w) = (src.height(), src.width());
|
||||
let dest = if dest.is_single() { RangeRef::new(dest.start, dest.start.offset_clamped(h as i64 - 1, w as i64 - 1)) } else { dest };
|
||||
// Whole rows or columns take a line format, as the ribbon's formatting commands give
|
||||
// them, instead of a style on each of their cells (a column alone has a million). The
|
||||
// line format comes from the first cell of the source pattern across the line; cells
|
||||
// already stored follow the pattern. Row heights and column widths stay as they are.
|
||||
let rows = dest.is_full_rows() && !dest.is_full_cols();
|
||||
let cols = dest.is_full_cols() && !dest.is_full_rows();
|
||||
let src_full = (rows && src.is_full_rows()) || (cols && src.is_full_cols());
|
||||
if rows || cols {
|
||||
let (first, src_first, n) = if rows { (dest.start.row, src.start.row, h) } else { (dest.start.col, src.start.col, w) };
|
||||
let lines = if rows { dest.start.row..=dest.end.row } else { dest.start.col..=dest.end.col };
|
||||
for line in lines {
|
||||
let sl = src_first + (line - first) % n;
|
||||
// The source cell for position `k` along the line.
|
||||
let at = |k: u32| if rows { CellRef::new(sl, src.start.col + k % w) } else { CellRef::new(src.start.row + k % h, sl) };
|
||||
let line_style = if src_full {
|
||||
if rows { src_sheet.rows.get(&sl) } else { src_sheet.cols.get(&sl) }.and_then(|i| i.style)
|
||||
} else {
|
||||
Some(src_sheet.style_id(at(0)))
|
||||
};
|
||||
let line_style = line_style.map(|st| if same { st } else { cx.wb.styles.intern(wb.styles.get(st).clone()) });
|
||||
let (dest_line, src_line) = if rows {
|
||||
(RangeRef::rows(line, line), RangeRef::rows(sl, sl))
|
||||
} else {
|
||||
(RangeRef::cols(line, line), RangeRef::cols(sl, sl))
|
||||
};
|
||||
let mut cells: Vec<CellRef> =
|
||||
cx.wb.sheet(dest_sheet).map(|sh| sh.cells.iter_range(dest_line).map(|(c, _)| c).collect()).unwrap_or_default();
|
||||
if src_full {
|
||||
cells.extend(
|
||||
src_sheet
|
||||
.cells
|
||||
.iter_range(src_line)
|
||||
.map(|(c, _)| if rows { CellRef::new(line, c.col) } else { CellRef::new(c.row, line) }),
|
||||
);
|
||||
}
|
||||
if cols {
|
||||
// A row format outranks a column format: the column's cells in formatted
|
||||
// rows need a style of their own.
|
||||
cells.extend(
|
||||
cx.wb
|
||||
.sheet(dest_sheet)
|
||||
.into_iter()
|
||||
.flat_map(|sh| sh.rows.iter().filter(|(_, i)| i.style.is_some()).map(|(r, _)| CellRef::new(*r, line))),
|
||||
);
|
||||
}
|
||||
let sh = cx.sheet_mut(dest_sheet)?;
|
||||
(if rows { &mut sh.rows } else { &mut sh.cols }).entry(line).or_default().style = line_style;
|
||||
for c in cells {
|
||||
let st = src_sheet.style_id(at(if rows { c.col } else { c.row }));
|
||||
let st = if same { st } else { cx.wb.styles.intern(wb.styles.get(st).clone()) };
|
||||
cx.sheet_mut(dest_sheet)?.set_style(c, st);
|
||||
cx.touch(dest_sheet, c);
|
||||
}
|
||||
}
|
||||
*cx.sel = crate::Selection::range(dest);
|
||||
return Ok(());
|
||||
}
|
||||
for c in dest.iter().take(2_000_000) {
|
||||
let sc = CellRef::new(src.start.row + (c.row - dest.start.row) % h, src.start.col + (c.col - dest.start.col) % w);
|
||||
let st = src_sheet.style_id(sc);
|
||||
@@ -1604,6 +1789,55 @@ mod tests {
|
||||
assert!(s.execute("cell.set", json!({"cell": "A5"})).is_err());
|
||||
}
|
||||
|
||||
/// Every string in `values` went through typed input, read with en-US rules, in silence: a
|
||||
/// caller sending "05.04.2026" from a dd.mm.yyyy export stored 4 May, "2.500" became 2.5 and
|
||||
/// "4.25 %" became a percentage (#2255). `parse: false` stores a string as the text it is.
|
||||
#[test]
|
||||
fn set_values_can_store_strings_as_the_text_they_are() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let strings = ["05.04.2026", "02/11/2026", "2.500", "4.25 %", "=1+1", "'keep"];
|
||||
let row: Vec<Json> = strings.iter().map(|v| json!(v)).collect();
|
||||
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
|
||||
|
||||
// The default is unchanged: the dates swap, the thousands separator is a decimal point.
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [row.clone()]})).unwrap();
|
||||
assert_eq!(get(&mut s, "A1")["text"], "5/4/2026", "dd.mm.yyyy read as mm.dd.yyyy");
|
||||
assert_eq!(get(&mut s, "C1")["value"], json!(2.5), "\"2.500\" read as a decimal");
|
||||
assert_eq!(get(&mut s, "D1")["value"], json!(0.0425), "\"4.25 %\" read as a percentage");
|
||||
|
||||
// With `parse: false` each string is the text it was given, and a formula is still a
|
||||
// formula.
|
||||
s.execute("range.setValues", json!({"range": "A3", "parse": false, "values": [row.clone()]})).unwrap();
|
||||
for (i, want) in strings.iter().enumerate() {
|
||||
let cell = format!("{}3", (b'A' + i as u8) as char);
|
||||
let got = get(&mut s, &cell);
|
||||
if *want == "=1+1" {
|
||||
assert_eq!(got["value"], json!(2.0), "{cell} is still a formula: {got}");
|
||||
assert_eq!(got["formula"], "=1+1", "{got}");
|
||||
} else {
|
||||
assert_eq!(got["value"], json!(*want), "{cell} is the text it was given: {got}");
|
||||
assert!(got["formula"].is_null(), "{cell} is not a formula: {got}");
|
||||
}
|
||||
}
|
||||
// Numbers and booleans are untouched either way.
|
||||
s.execute("range.setValues", json!({"range": "A5", "parse": false, "values": [[12.5, true, null]]})).unwrap();
|
||||
assert_eq!(get(&mut s, "A5")["value"], json!(12.5));
|
||||
assert_eq!(get(&mut s, "B5")["value"], json!(true));
|
||||
assert_eq!(get(&mut s, "C5")["text"], "");
|
||||
// `parse` is a flag, and anything else is a usage error rather than a quiet default.
|
||||
s.execute("range.setValues", json!({"range": "A7", "parse": true, "values": [["2.500"]]})).unwrap();
|
||||
assert_eq!(get(&mut s, "A7")["value"], json!(2.5));
|
||||
assert!(s.execute("range.setValues", json!({"range": "A8", "parse": "no", "values": [["x"]]})).is_err());
|
||||
assert!(s.execute("range.setValues", json!({"range": "A8", "parse": 0, "values": [["x"]]})).is_err());
|
||||
// A line break still turns on Wrap Text.
|
||||
s.execute("range.setValues", json!({"range": "A9", "parse": false, "values": [["one\ntwo"]]})).unwrap();
|
||||
assert_eq!(get(&mut s, "A9")["value"], json!("one\ntwo"));
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.active().unwrap();
|
||||
assert!(d.wb.styles.get(sh.style_id(CellRef::parse("A9").unwrap())).align.wrap);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_array_entry() {
|
||||
let mut s = Session::new();
|
||||
@@ -1675,8 +1909,44 @@ mod tests {
|
||||
assert_eq!(get(&mut s, "E1")["value"], json!(5.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_paste_that_overflows_the_grid_moves_nothing() {
|
||||
// #997: the source was emptied first and the cells that fell off the grid were skipped.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2]]})).unwrap();
|
||||
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
|
||||
s.execute("edit.cut", json!({"range": "A1:A2"})).unwrap();
|
||||
// Transposed, the 2 rows become 2 columns and the second one is past XFD.
|
||||
assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "transpose": true, "at": "XFD1"})).is_err());
|
||||
assert_eq!(get(&mut s, "A1")["value"], json!(1.0));
|
||||
assert_eq!(get(&mut s, "A2")["value"], json!(2.0));
|
||||
// Not transposed, the second row would be past the last row.
|
||||
assert!(s.execute("edit.pasteSpecial", json!({"what": "all", "at": "A1048576"})).is_err());
|
||||
assert_eq!(get(&mut s, "A1")["value"], json!(1.0));
|
||||
assert_eq!(get(&mut s, "A2")["value"], json!(2.0));
|
||||
// Not transposed it still fits in column XFD.
|
||||
s.execute("edit.pasteSpecial", json!({"what": "all", "at": "XFD1"})).unwrap();
|
||||
assert_eq!(get(&mut s, "XFD1")["value"], json!(1.0));
|
||||
assert_eq!(get(&mut s, "XFD2")["value"], json!(2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_case_insensitive() {
|
||||
assert_eq!(replace_ci("Apple apple APPLE", "apple", "pear"), "pear pear pear");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_case_insensitive_keeps_text_when_folding_moves_boundaries() {
|
||||
// U+212A KELVIN SIGN folds to `k` (3→1 bytes) while U+0130 folds to `i` plus a
|
||||
// combining dot (2→3 bytes): the folded text is the same length but its byte positions
|
||||
// no longer line up with the original, and the old slicing dropped the tail.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let text = "\u{212A}\u{0130}\u{0130}";
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": text})).unwrap();
|
||||
s.execute("edit.replace", json!({"what": "k", "with": "x"})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sh.value(CellRef::new(0, 0)), Value::text(text));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Rotate",
|
||||
["Page Layout", "Arrange"],
|
||||
None,
|
||||
"{kind: shape, id, flip?: horizontal|vertical} (swaps width/height for 90° turns)",
|
||||
"{kind: shape, id} (swaps width/height for a 90° turn; `flip` is rejected: shapes have no flip state)",
|
||||
has_doc,
|
||||
rotate
|
||||
),
|
||||
@@ -193,7 +193,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
new_script
|
||||
),
|
||||
cmd!(query "automate.allScripts", "All Scripts", ["Automate", "Scripting Tools"], None, "{} → saved scripts", has_doc, all_scripts),
|
||||
cmd!("automate.runScript", "Run Script", [], None, "{name}", has_doc, run_script),
|
||||
cmd!(
|
||||
"automate.runScript",
|
||||
"Run Script",
|
||||
[],
|
||||
None,
|
||||
"{name, trusted?: bool (runs a script from an untrusted workbook this once)} — refuses scripts in a workbook opened from a file until it is trusted, and steps that reach outside the workbook (file.*, link.*, any `path` param)",
|
||||
has_doc,
|
||||
run_script
|
||||
),
|
||||
cmd!(
|
||||
noundo "automate.trustScripts",
|
||||
"Trust Scripts",
|
||||
["Automate", "Scripting Tools"],
|
||||
None,
|
||||
"{trusted?: bool (default true)} → {trusted}: lets this workbook's scripts run for the rest of the session",
|
||||
has_doc,
|
||||
trust_scripts
|
||||
),
|
||||
cmd!("automate.deleteScript", "Delete Script", [], None, "{name}", has_doc, delete_script),
|
||||
// Cell controls, themes, borders, styles
|
||||
cmd!(
|
||||
@@ -351,7 +368,7 @@ fn add_element(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
/// The trendline described by `chart.addElement`'s trendline parameters (Excel's Add Chart
|
||||
/// Element ▸ Trendline choices, plus the Format Trendline options).
|
||||
fn trendline_param(p: &Json) -> Result<Trendline> {
|
||||
let num = |k: &str| f64_param(p, k).filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0);
|
||||
let num = |k: &str| Trendline::clamp_forecast(f64_param(p, k).unwrap_or(0.0));
|
||||
let mut t = Trendline {
|
||||
forward: num("forward"),
|
||||
backward: num("backward"),
|
||||
@@ -662,6 +679,9 @@ fn align(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
fn rotate(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if p.get("flip").is_some_and(|f| !f.is_null()) {
|
||||
return Err(bad("arrange.rotate", "`flip` is not supported: shapes have no flip state"));
|
||||
}
|
||||
with_shape(s, p, |sh| std::mem::swap(&mut sh.anchor.width, &mut sh.anchor.height))
|
||||
}
|
||||
|
||||
@@ -782,6 +802,7 @@ fn new_window(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let d = s.doc()?.clone();
|
||||
let mut copy = crate::DocState::new((*d.wb).clone(), d.path.clone(), format!("{}:2", d.display_title()));
|
||||
copy.selection = d.selection.clone();
|
||||
copy.scripts_trusted = d.scripts_trusted;
|
||||
let i = s.add_document(copy);
|
||||
Ok(json!({"index": i}))
|
||||
}
|
||||
@@ -828,6 +849,9 @@ fn table_resize(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
edit(s, |cx| {
|
||||
let sheet = cx.sheet_mut(si)?;
|
||||
let headers: Vec<String> = (r.start.col..=r.end.col).map(|c| sheet.value(CellRef::new(r.start.row, c)).display()).collect();
|
||||
if sheet.tables.iter().enumerate().any(|(i, o)| i != ti && o.range.intersects(&r)) {
|
||||
return Err(EngineError::Other("A table can't overlap another table.".into()));
|
||||
}
|
||||
let t = sheet.tables.get_mut(ti).ok_or_else(|| bad("table", "gone"))?;
|
||||
if r.start.row != t.range.start.row || !r.intersects(&t.range) {
|
||||
return Err(EngineError::Other(
|
||||
@@ -970,10 +994,13 @@ fn new_script(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
fn all_scripts(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let wb = &s.doc()?.wb;
|
||||
let d = s.doc()?;
|
||||
let (wb, trusted) = (&d.wb, d.scripts_trusted);
|
||||
let all = stash_get(wb, SCRIPTS_NAME);
|
||||
let mut list: Vec<Json> =
|
||||
all.as_object().map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len)})).collect()).unwrap_or_default();
|
||||
let mut list: Vec<Json> = all
|
||||
.as_object()
|
||||
.map(|o| o.iter().map(|(k, v)| json!({"name": k, "steps": v.as_array().map_or(0, Vec::len), "trusted": trusted})).collect())
|
||||
.unwrap_or_default();
|
||||
// A preserved VBA project is reported alongside GridCraft's own scripts; it is carried on
|
||||
// save but never executed here, so it has no step count.
|
||||
if let Some(bytes) = &wb.vba_project {
|
||||
@@ -982,17 +1009,79 @@ fn all_scripts(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
Ok(Json::Array(list))
|
||||
}
|
||||
|
||||
/// Whether `wb` carries GridCraft scripts (a workbook opened with them starts untrusted).
|
||||
pub(crate) fn has_scripts(wb: &Workbook) -> bool {
|
||||
stash_map(wb, SCRIPTS_NAME).as_object().is_some_and(|o| !o.is_empty())
|
||||
}
|
||||
|
||||
/// Why a script step is refused, whatever the workbook's trust: scripts stay inside the
|
||||
/// workbook. Anything that reads or writes files (`file.*`, or any `path` parameter: Get Data,
|
||||
/// From Text/CSV, Insert Picture) or leaves the app (`link.*` opens URLs) is the caller's to run
|
||||
/// directly, not a stored script's. (Nested `automate.*` steps are skipped.) This check goes by
|
||||
/// command id and parameter name, so it is only the up-front refusal: the steps also run inside
|
||||
/// an [`crate::io::ScriptSandbox`] (file access refused whatever the parameter is called) and
|
||||
/// [`run_script`] drops any `OpenUrl` request a step raises.
|
||||
fn denied_script_step(id: &str, params: &Json) -> Option<String> {
|
||||
if id.starts_with("file.") || id.starts_with("link.") {
|
||||
return Some(format!("scripts can't run `{id}`: it reaches outside the workbook"));
|
||||
}
|
||||
if params.get("path").is_some() {
|
||||
return Some(format!("scripts can't pass a file `path` to `{id}`"));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn trust_scripts(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
// Only a missing `trusted` means "trust"; `"false"`, `0` or `null` are mistakes, not consent.
|
||||
let trusted = match p.get("trusted") {
|
||||
None => true,
|
||||
Some(Json::Bool(b)) => *b,
|
||||
Some(_) => return Err(bad("automate.trustScripts", "`trusted` must be true or false")),
|
||||
};
|
||||
s.doc_mut()?.scripts_trusted = trusted;
|
||||
Ok(json!({"trusted": trusted}))
|
||||
}
|
||||
|
||||
/// Runs a stored script. Scripts in a workbook opened from a file run only once the workbook is
|
||||
/// trusted for this session (`automate.trustScripts`, or `trusted: true` on this call); steps that
|
||||
/// reach outside the workbook ([`denied_script_step`]) are refused before any step runs.
|
||||
fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("automate.runScript", "missing `name`"))?;
|
||||
let all = stash_get(&s.doc()?.wb, SCRIPTS_NAME);
|
||||
let d = s.doc()?;
|
||||
let all = stash_get(&d.wb, SCRIPTS_NAME);
|
||||
let steps = all.get(name).and_then(Json::as_array).cloned().ok_or_else(|| bad("automate.runScript", "no such script"))?;
|
||||
if !d.scripts_trusted && !bool_param(p, "trusted").unwrap_or(false) {
|
||||
return Err(bad(
|
||||
"automate.runScript",
|
||||
"scripts in this workbook came from a file and are disabled; trust the workbook first (Trust Scripts in View › Macros, Alt+F8; `automate.trustScripts`; or `trusted: true`)",
|
||||
));
|
||||
}
|
||||
let steps: Vec<Json> = steps.into_iter().take(100_000).collect();
|
||||
for st in &steps {
|
||||
let id = st["command"].as_str().unwrap_or("");
|
||||
if let Some(why) = denied_script_step(id, &st["params"]) {
|
||||
return Err(bad("automate.runScript", why));
|
||||
}
|
||||
}
|
||||
let _sandbox = crate::io::ScriptSandbox::enter();
|
||||
let mut n = 0;
|
||||
for st in steps.iter().take(100_000) {
|
||||
for st in &steps {
|
||||
let id = st["command"].as_str().unwrap_or("");
|
||||
if id.starts_with("automate.") {
|
||||
continue;
|
||||
}
|
||||
s.execute(id, st["params"].clone())?;
|
||||
let before = s.ui_requests.len();
|
||||
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(_)))) {
|
||||
let mut i = 0;
|
||||
s.ui_requests.retain(|r| {
|
||||
i += 1;
|
||||
i <= before || !matches!(r, crate::UiRequest::OpenUrl(_))
|
||||
});
|
||||
return Err(bad("automate.runScript", format!("scripts can't open links (`{id}`)")));
|
||||
}
|
||||
ran?;
|
||||
n += 1;
|
||||
}
|
||||
Ok(json!({"ran": n}))
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::DocState;
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(noundo "file.new", "New Workbook", ["File"], Some("Cmd+N"), "{sample?: \"budget\"|\"sales\"|\"grades\"}", always, new_workbook),
|
||||
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64}, plus {password} for a password-protected workbook (xlsx, xlsm, xls import, xlsb and ods data import, csv, tsv, txt, json)", always, open),
|
||||
cmd!(noundo "file.open", "Open…", ["File"], Some("Cmd+O"), "{path} | {name, base64}, plus {password} for a password-protected workbook and {csvAsText?: bool} to keep every csv/tsv/txt field as text, with no number or date conversion (xlsx, xlsm, xls import, xlsb and ods data import, csv, tsv, txt, json)", always, open),
|
||||
cmd!(noundo "file.save", "Save", ["File"], Some("Cmd+S"), "{path?} (xlsx by default; .csv/.tsv/.json/.html/.pdf by extension)", has_doc, save),
|
||||
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),
|
||||
@@ -56,8 +56,15 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
return ok();
|
||||
};
|
||||
let encrypted = gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Encrypted;
|
||||
let (mut wb, warnings) = crate::io::open_bytes_with_password(&name, &bytes, str_param(p, "password"))?;
|
||||
let (mut wb, warnings) = crate::io::open_bytes_with(&name, &bytes, str_param(p, "password"), bool_param(p, "csvAsText").unwrap_or(false))?;
|
||||
wb.source_dir = str_param(p, "path").and_then(crate::io::source_dir);
|
||||
// What `CELL("filename")` reports (#1489). A workbook opened from bytes knows its name but
|
||||
// never a folder, so it reports `[name]Sheet` with none; an import reports the file it came
|
||||
// from, as Excel does, even though Save must still ask where to put the .xlsx.
|
||||
wb.source_path = match str_param(p, "path") {
|
||||
Some(path) => crate::io::source_path(path),
|
||||
None => Some(std::path::PathBuf::from(&name)),
|
||||
};
|
||||
let imported =
|
||||
matches!(crate::io::FileKind::from_path(&name), Some(crate::io::FileKind::Ods | crate::io::FileKind::Xlsb | crate::io::FileKind::Xls))
|
||||
|| matches!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Ods | gridcraft_xlsx::Format::Xlsb | gridcraft_xlsx::Format::Xls);
|
||||
@@ -122,6 +129,13 @@ fn save(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
crate::io::write_file(&path, &bytes)?;
|
||||
// Only a full-fidelity format clears the dirty flag and becomes the document's file.
|
||||
if matches!(kind, Some(crate::io::FileKind::Xlsx | crate::io::FileKind::Json)) {
|
||||
// The new file is the workbook's own now, so `CELL("filename")` reports it (#1489). Set
|
||||
// before the `saved` snapshot, or the workbook would read as dirty the moment it was
|
||||
// saved; and only when it changes, so an ordinary Save does not clone the workbook.
|
||||
let file = crate::io::source_path(&path);
|
||||
if d.wb.source_path != file {
|
||||
std::sync::Arc::make_mut(&mut d.wb).source_path = file;
|
||||
}
|
||||
d.saved = d.wb.clone();
|
||||
d.path = Some(path.clone());
|
||||
}
|
||||
@@ -139,8 +153,9 @@ fn save_as(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
fn save_bytes(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let fmt = str_param(p, "format").unwrap_or("xlsx");
|
||||
let d = s.doc()?;
|
||||
let bytes = crate::io::save_bytes(&d.wb, &format!("x.{fmt}"))?;
|
||||
let name = format!("{}.{fmt}", std::path::Path::new(&d.display_title()).file_stem().and_then(|x| x.to_str()).unwrap_or("Book"));
|
||||
// The file name goes into a PivotChart's link to its PivotTable.
|
||||
let bytes = crate::io::save_bytes(&d.wb, &name)?;
|
||||
Ok(json!({"name": name, "base64": crate::io::base64_encode(&bytes), "bytes": bytes.len()}))
|
||||
}
|
||||
|
||||
|
||||
@@ -411,6 +411,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: (opts.column_chart && c >= 3).then_some(ChartKind::Line),
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
})
|
||||
|
||||
+175
-50
@@ -133,9 +133,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
number_format
|
||||
),
|
||||
cmd!("home.accounting", "Accounting Number Format", ["Home", "Number"], None, "{range?, symbol?: \"$\"}", has_doc, |s, p| set_fmt(
|
||||
s, p, ACCOUNTING
|
||||
)),
|
||||
cmd!("home.accounting", "Accounting Number Format", ["Home", "Number"], None, "{range?, symbol?: \"$\"}", has_doc, accounting),
|
||||
cmd!("home.percent", "Percent Style", ["Home", "Number"], Some("Ctrl+Shift+%"), "{range?}", has_doc, |s, p| set_fmt(s, p, "0%")),
|
||||
cmd!("home.comma", "Comma Style", ["Home", "Number"], None, "{range?}", has_doc, |s, p| set_fmt(s, p, COMMA)),
|
||||
cmd!("home.increaseDecimal", "Increase Decimal", ["Home", "Number"], None, "{range?}", has_doc, |s, p| decimals(s, p, 1)),
|
||||
@@ -156,7 +154,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Row Height…",
|
||||
["Home", "Cells", "Format"],
|
||||
None,
|
||||
"{rows?: \"2:5\" | \"2:2,4:5\", height: 20 (points on screen)}",
|
||||
"{sheet?, rows?: \"2:5\" | \"2:2,4:5\" | \"Sheet2!2:5\", height: 20 (pixels) | points: 15}",
|
||||
has_doc,
|
||||
row_height
|
||||
),
|
||||
@@ -165,28 +163,34 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Column Width…",
|
||||
["Home", "Cells", "Format"],
|
||||
None,
|
||||
"{cols?: \"B:D\" | \"B:B,D:F\", width: 64 (points) | chars: 8.43}",
|
||||
"{sheet?, cols?: \"B:D\" | \"B:B,D:F\" | \"Sheet2!B:D\", width: 64 (pixels) | chars: 8.43}",
|
||||
has_doc,
|
||||
col_width
|
||||
),
|
||||
cmd!("home.autofitRowHeight", "AutoFit Row Height", ["Home", "Cells", "Format"], None, "{rows?}", has_doc, autofit_rows),
|
||||
cmd!("home.autofitColumnWidth", "AutoFit Column Width", ["Home", "Cells", "Format"], None, "{cols?}", has_doc, autofit_cols),
|
||||
cmd!("home.defaultWidth", "Default Width…", ["Home", "Cells", "Format"], None, "{width: 64}", has_doc, default_width),
|
||||
cmd!("home.hideRows", "Hide Rows", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+9"), "{rows?}", has_doc, |s, p| hide(
|
||||
cmd!("home.autofitRowHeight", "AutoFit Row Height", ["Home", "Cells", "Format"], None, "{sheet?, rows?}", has_doc, autofit_rows),
|
||||
cmd!("home.autofitColumnWidth", "AutoFit Column Width", ["Home", "Cells", "Format"], None, "{sheet?, cols?}", has_doc, autofit_cols),
|
||||
cmd!("home.defaultWidth", "Default Width…", ["Home", "Cells", "Format"], None, "{sheet?, width: 64 (pixels)}", has_doc, default_width),
|
||||
cmd!("home.hideRows", "Hide Rows", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+9"), "{sheet?, rows?}", has_doc, |s, p| hide(
|
||||
s, p, true, true
|
||||
)),
|
||||
cmd!("home.hideColumns", "Hide Columns", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+0"), "{cols?}", has_doc, |s, p| hide(
|
||||
s, p, false, true
|
||||
)),
|
||||
cmd!("home.unhideRows", "Unhide Rows", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+Shift+9"), "{rows?}", has_doc, |s, p| hide(
|
||||
s, p, true, false
|
||||
)),
|
||||
cmd!("home.hideColumns", "Hide Columns", ["Home", "Cells", "Format", "Hide & Unhide"], Some("Cmd+0"), "{sheet?, cols?}", has_doc, |s, p| {
|
||||
hide(s, p, false, true)
|
||||
}),
|
||||
cmd!(
|
||||
"home.unhideRows",
|
||||
"Unhide Rows",
|
||||
["Home", "Cells", "Format", "Hide & Unhide"],
|
||||
Some("Cmd+Shift+9"),
|
||||
"{sheet?, rows?}",
|
||||
has_doc,
|
||||
|s, p| hide(s, p, true, false)
|
||||
),
|
||||
cmd!(
|
||||
"home.unhideColumns",
|
||||
"Unhide Columns",
|
||||
["Home", "Cells", "Format", "Hide & Unhide"],
|
||||
Some("Cmd+Shift+0"),
|
||||
"{cols?}",
|
||||
"{sheet?, cols?}",
|
||||
has_doc,
|
||||
|s, p| hide(s, p, false, false)
|
||||
),
|
||||
@@ -202,6 +206,23 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
const ACCOUNTING: &str = "_(\"$\"* #,##0.00_);_(\"$\"* \\(#,##0.00\\);_(\"$\"* \"-\"??_);_(@_)";
|
||||
const COMMA: &str = "_(* #,##0.00_);_(* \\(#,##0.00\\);_(* \"-\"??_);_(@_)";
|
||||
|
||||
/// Accounting Number Format with the currency symbol to use (Excel's own button takes the system
|
||||
/// currency; `symbol` is how the control channel picks another one, e.g. `"L"` for lempiras).
|
||||
fn accounting(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let symbol = str_param(p, "symbol").unwrap_or("$");
|
||||
// The symbol goes into a quoted literal: a quote or a backslash would end it, and a control
|
||||
// character would reach the format's text as-is.
|
||||
if symbol.contains(['"', '\\']) || symbol.chars().any(char::is_control) {
|
||||
return Err(bad("home.accounting", "the symbol can't contain a quote, a backslash or a control character"));
|
||||
}
|
||||
set_fmt(s, p, &accounting_format(symbol))
|
||||
}
|
||||
|
||||
fn accounting_format(symbol: &str) -> String {
|
||||
let symbol = format!("\"{symbol}\"");
|
||||
format!("_({symbol}* #,##0.00_);_({symbol}* \\(#,##0.00\\);_({symbol}* \"-\"??_);_(@_)")
|
||||
}
|
||||
|
||||
fn with(p: &Json, key: &str, v: Json) -> Json {
|
||||
let mut m = p.as_object().cloned().unwrap_or_default();
|
||||
m.insert(key.into(), v);
|
||||
@@ -296,7 +317,11 @@ fn underline(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
fn font_name(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let Some(name) = str_param(p, "name").map(str::to_string) else { return Err(bad("home.fontName", "missing `name`")) };
|
||||
apply_style(s, p, |st| st.font.name = name.clone())
|
||||
apply_style(s, p, |st| {
|
||||
st.font.name = name.clone();
|
||||
st.font.charset = None;
|
||||
st.font.family = None;
|
||||
})
|
||||
}
|
||||
|
||||
fn font_size(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
@@ -488,7 +513,7 @@ fn borders(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
fn format_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let Some(patch) = p.get("style").cloned() else {
|
||||
let Some(patch) = p.get("style").map(snake_case_keys) else {
|
||||
// Menu invocation without params: the UI opens the dialog.
|
||||
s.ui_requests.push(crate::UiRequest::Dialog("formatCells".into(), json!({})));
|
||||
return ok();
|
||||
@@ -506,6 +531,32 @@ fn format_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The style patch with camelCase keys (`numFmt`, `diagDown`, as documented) renamed to the
|
||||
/// style's own snake_case (`num_fmt`, `diag_down`). Keys starting with a capital (`Rgb`) stay.
|
||||
fn snake_case_keys(v: &Json) -> Json {
|
||||
match v {
|
||||
Json::Object(m) => Json::Object(
|
||||
m.iter()
|
||||
.map(|(k, v)| {
|
||||
let key = if k.starts_with(|c: char| c.is_ascii_lowercase()) {
|
||||
k.chars().fold(String::new(), |mut out, c| {
|
||||
if c.is_ascii_uppercase() {
|
||||
out.push('_');
|
||||
}
|
||||
out.push(c.to_ascii_lowercase());
|
||||
out
|
||||
})
|
||||
} else {
|
||||
k.clone()
|
||||
};
|
||||
(key, snake_case_keys(v))
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deep-merges `patch` into `base` (objects merge, everything else replaces).
|
||||
pub(crate) fn merge_json(mut base: Json, patch: &Json) -> Json {
|
||||
match (&mut base, patch) {
|
||||
@@ -933,29 +984,60 @@ fn cell_style(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
// ---------------------------------------------------------------- sizes
|
||||
|
||||
fn line_targets(s: &Session, p: &Json, rows: bool) -> Result<Vec<(u32, u32)>> {
|
||||
/// The sheet and the row (or column) spans a size or visibility command works on. The lines are
|
||||
/// `rows`/`cols` (`"2:5"`, `"B:B,D:F"`, optionally `Sheet!`-prefixed), else the selection's; the
|
||||
/// sheet is `sheet`, else the prefix, else the active one. The selection belongs to the active
|
||||
/// sheet, so another sheet needs explicit lines.
|
||||
fn line_targets(s: &Session, p: &Json, rows: bool) -> Result<(usize, Vec<(u32, u32)>)> {
|
||||
let key = if rows { "rows" } else { "cols" };
|
||||
let mut sheet = target_sheet(s, p)?;
|
||||
if let Some(t) = str_param(p, key) {
|
||||
let (prefix, body) = split_sheet(t);
|
||||
if let Some(name) = prefix {
|
||||
let named = s.doc()?.wb.sheet_index(&name).ok_or_else(|| bad(key, format!("no sheet named `{name}`")))?;
|
||||
if p.get("sheet").is_some_and(|v| !v.is_null()) && named != sheet {
|
||||
return Err(bad(key, format!("`{t}` names a sheet other than `sheet`")));
|
||||
}
|
||||
sheet = named;
|
||||
}
|
||||
// A comma-separated list ("B:B,D:F") targets several areas, like a multi-area selection.
|
||||
return t
|
||||
let lines = body
|
||||
.split(',')
|
||||
.map(|part| {
|
||||
let r = RangeRef::parse(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?;
|
||||
let r = parse_lines(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?;
|
||||
Ok(if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) })
|
||||
})
|
||||
.collect();
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
return Ok((sheet, lines));
|
||||
}
|
||||
Ok(s.doc()?.selection.ranges.iter().map(|r| if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }).collect())
|
||||
let d = s.doc()?;
|
||||
if sheet != d.wb.active_sheet {
|
||||
return Err(bad(key, format!("missing `{key}`: the selection is on the active sheet")));
|
||||
}
|
||||
Ok((sheet, d.selection.ranges.iter().map(|r| if rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }).collect()))
|
||||
}
|
||||
|
||||
/// Sizes are pixels at 96 dpi, the model's unit (header drags send pixels). `points` (row height)
|
||||
/// and `chars` (column width) take Excel's units, those of its Row Height and Column Width dialogs
|
||||
/// and of the file (`ht` is in points).
|
||||
fn row_height(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let h = f64_param(p, "height").ok_or_else(|| bad("home.rowHeight", "missing `height`"))?;
|
||||
if !(0.0..=546.0).contains(&h) {
|
||||
return Err(EngineError::Other("Row height must be between 0 and 409 points.".into()));
|
||||
}
|
||||
let lines = line_targets(s, p, true)?;
|
||||
let h = match (f64_param(p, "height"), f64_param(p, "points")) {
|
||||
(Some(h), _) => {
|
||||
if !(0.0..=546.0).contains(&h) {
|
||||
return Err(EngineError::Other("Row height must be between 0 and 546 pixels (409.5 points).".into()));
|
||||
}
|
||||
h
|
||||
}
|
||||
(None, Some(pt)) => {
|
||||
if !(0.0..=409.5).contains(&pt) {
|
||||
return Err(EngineError::Other("Row height must be between 0 and 409.5 points.".into()));
|
||||
}
|
||||
pt * 4.0 / 3.0
|
||||
}
|
||||
_ => return Err(bad("home.rowHeight", "missing `height` (pixels) or `points`")),
|
||||
};
|
||||
let (si, lines) = line_targets(s, p, true)?;
|
||||
edit(s, |cx| {
|
||||
let si = cx.sheet_index();
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
for (a, b) in lines {
|
||||
for r in a..=b.min(a.saturating_add(1_048_575)) {
|
||||
@@ -972,16 +1054,22 @@ fn row_height(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
fn col_width(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let w = match (f64_param(p, "width"), f64_param(p, "chars")) {
|
||||
(Some(w), _) => w,
|
||||
(None, Some(c)) => chars_to_points(c),
|
||||
_ => return Err(bad("home.columnWidth", "missing `width`")),
|
||||
(Some(w), _) => {
|
||||
if !(0.0..=1800.0).contains(&w) {
|
||||
return Err(EngineError::Other("Column width must be between 0 and 1800 pixels.".into()));
|
||||
}
|
||||
w
|
||||
}
|
||||
(None, Some(c)) => {
|
||||
if !(0.0..=255.0).contains(&c) {
|
||||
return Err(EngineError::Other("Column width must be between 0 and 255 characters.".into()));
|
||||
}
|
||||
chars_to_points(c)
|
||||
}
|
||||
_ => return Err(bad("home.columnWidth", "missing `width` (pixels) or `chars`")),
|
||||
};
|
||||
if !(0.0..=1800.0).contains(&w) {
|
||||
return Err(EngineError::Other("Column width must be between 0 and 255 characters.".into()));
|
||||
}
|
||||
let lines = line_targets(s, p, false)?;
|
||||
let (si, lines) = line_targets(s, p, false)?;
|
||||
edit(s, |cx| {
|
||||
let si = cx.sheet_index();
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
for (a, b) in lines {
|
||||
for c in a..=b {
|
||||
@@ -994,7 +1082,7 @@ fn col_width(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Excel column width in characters (of the default font's digit width, 7 px) → points.
|
||||
/// Excel column width in characters (of the default font's digit width, 7 px) → pixels.
|
||||
pub fn chars_to_points(c: f64) -> f64 {
|
||||
if c <= 0.0 {
|
||||
0.0
|
||||
@@ -1008,7 +1096,7 @@ pub fn chars_to_points(c: f64) -> f64 {
|
||||
pub use gridcraft_model::points_to_chars;
|
||||
|
||||
/// Approximate text width in points for autofit (no font metrics in the engine: average glyph
|
||||
/// widths for the default font; the UI refines with real measurement when it autofits).
|
||||
/// widths for the default font; a front end with fonts measures instead, see `Session::text_width`).
|
||||
pub fn approx_text_width(text: &str, size: f32, bold: bool) -> f32 {
|
||||
let mut w = 0.0;
|
||||
for c in text.chars() {
|
||||
@@ -1025,12 +1113,18 @@ pub fn approx_text_width(text: &str, size: f32, bold: bool) -> f32 {
|
||||
w * size * if bold { 1.07 } else { 1.0 } * 96.0 / 72.0
|
||||
}
|
||||
|
||||
/// Distinct texts AutoFit measures exactly; past this many, `Session::text_width` is asked for a
|
||||
/// quick measure so a huge column doesn't lay out every value.
|
||||
const EXACT_TEXT_WIDTHS: usize = 2_000;
|
||||
|
||||
fn autofit_cols(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let lines = line_targets(s, p, false)?;
|
||||
let (si, lines) = line_targets(s, p, false)?;
|
||||
let measure = s.text_width.clone();
|
||||
edit(s, |cx| {
|
||||
let si = cx.sheet_index();
|
||||
let Some(sh) = cx.wb.sheet(si) else { return Ok(Json::Null) };
|
||||
let mut widths = Vec::new();
|
||||
let mut measured: std::collections::HashMap<StyleId, std::collections::HashMap<String, f32>> = Default::default();
|
||||
let mut distinct = 0usize;
|
||||
let used = sh.used_range();
|
||||
for (a, b) in lines {
|
||||
for c in a..=b.min(a + 16383) {
|
||||
@@ -1041,12 +1135,26 @@ fn autofit_cols(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if sh.merge_at(cell).is_some_and(|m| !m.is_single()) {
|
||||
continue;
|
||||
}
|
||||
let st = cx.wb.styles.get(sh.style_id(cell));
|
||||
let id = sh.style_id(cell);
|
||||
let st = cx.wb.styles.get(id);
|
||||
let text = crate::display::cell_text(&cx.wb, sh, cell);
|
||||
for line in text.lines() {
|
||||
max = max.max(
|
||||
approx_text_width(line, st.font.size, st.font.bold) + 6.0 + st.align.indent as f32 * 9.0 + sh.filter_button_space(cell),
|
||||
);
|
||||
let w = match &measure {
|
||||
Some(m) => {
|
||||
let known = measured.entry(id).or_default();
|
||||
match known.get(line) {
|
||||
Some(w) => *w,
|
||||
None => {
|
||||
let w = m(line, &st.font, distinct < EXACT_TEXT_WIDTHS);
|
||||
known.insert(line.to_owned(), w);
|
||||
distinct += 1;
|
||||
w
|
||||
}
|
||||
}
|
||||
}
|
||||
None => approx_text_width(line, st.font.size, st.font.bold),
|
||||
};
|
||||
max = max.max(w + 6.0 + st.align.indent as f32 * 9.0 + sh.filter_button_space(cell));
|
||||
}
|
||||
}
|
||||
if max > 0.0 {
|
||||
@@ -1065,9 +1173,8 @@ fn autofit_cols(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
fn autofit_rows(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let lines = line_targets(s, p, true)?;
|
||||
let (si, lines) = line_targets(s, p, true)?;
|
||||
edit(s, |cx| {
|
||||
let si = cx.sheet_index();
|
||||
for (a, b) in lines {
|
||||
let b = b.min(a + 100_000);
|
||||
if let Some(sh) = cx.wb.sheet_mut(si) {
|
||||
@@ -1087,17 +1194,16 @@ fn autofit_rows(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
fn default_width(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let w = f64_param(p, "width").ok_or_else(|| bad("home.defaultWidth", "missing `width`"))?;
|
||||
let si = target_sheet(s, p)?;
|
||||
edit(s, |cx| {
|
||||
let si = cx.sheet_index();
|
||||
cx.sheet_mut(si)?.default_col_width = w.clamp(0.0, 1800.0) as f32;
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
||||
fn hide(s: &mut Session, p: &Json, rows: bool, on: bool) -> Result<Json> {
|
||||
let lines = line_targets(s, p, rows)?;
|
||||
let (si, lines) = line_targets(s, p, rows)?;
|
||||
edit(s, |cx| {
|
||||
let si = cx.sheet_index();
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
for (a, b) in lines {
|
||||
// Unhiding a single selected line also reveals hidden neighbours (Excel needs the
|
||||
@@ -1137,6 +1243,11 @@ mod tests {
|
||||
assert_eq!(v(&mut s, "D1"), json!(4.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_symbol_reproduces_the_accounting_format() {
|
||||
assert_eq!(accounting_format("$"), ACCOUNTING);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_steps() {
|
||||
let v = gridcraft_core::Value::Number(1.5);
|
||||
@@ -1149,6 +1260,20 @@ mod tests {
|
||||
assert_eq!(adjust_decimals("0%", &v, 1), "0.0%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_cells_takes_the_documented_keys() {
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "5"})).unwrap();
|
||||
s.execute("home.formatCells", json!({"range": "A1", "style": {"numFmt": "0.0", "borders": {"diagUp": {"style": "Thin"}}}})).unwrap();
|
||||
let st = &s.execute("cell.get", json!({"cell": "A1"})).unwrap()["style"];
|
||||
assert_eq!(st["num_fmt"], "0.0");
|
||||
assert_eq!(st["borders"]["diag_up"]["style"], "Thin");
|
||||
// The style's own snake_case keys keep working.
|
||||
s.execute("home.formatCells", json!({"range": "A1", "style": {"num_fmt": "0.00"}})).unwrap();
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["style"]["num_fmt"], "0.00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn width_units() {
|
||||
assert_eq!(chars_to_points(8.43), 64.0);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Formulas tab: AutoSum, Insert Function, names, auditing, calculation.
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef};
|
||||
use gridcraft_core::{CellError, CellRef, RangeRef, Value};
|
||||
use gridcraft_model::{CalcMode, DefinedName, Formula};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
@@ -49,11 +49,22 @@ 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!(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()),
|
||||
cmd!("formulas.showFormulas", "Show Formulas", ["Formulas", "Formula Auditing"], Some("Ctrl+`"), "{on?}", has_doc, show_formulas),
|
||||
cmd!(
|
||||
"formulas.convertToNumber",
|
||||
"Convert to Number",
|
||||
[],
|
||||
None,
|
||||
"{range?} → {converted} (numbers stored as text in the range or selection become numbers)",
|
||||
has_doc,
|
||||
convert_to_number
|
||||
),
|
||||
cmd!(query "formulas.errorChecking", "Error Checking", ["Formulas", "Formula Auditing"], None, "{} → cells with errors", has_doc, error_checking),
|
||||
cmd!(query "formulas.evaluateFormula", "Evaluate Formula", ["Formulas", "Formula Auditing"], None, "{cell?, formula?} → steps", has_doc, evaluate_formula),
|
||||
cmd!(query "formulas.evaluate", "Evaluate", [], None, "{formula: \"=SUM(A1:A3)\", cell?} → value without changing the sheet", has_doc, evaluate),
|
||||
cmd!(query "formulas.evaluate", "Evaluate", [], None, "{formula: \"=SUM(A1:A3)\", cell?} → value without changing the sheet; a formula that couldn't be entered in a cell is an error", has_doc, evaluate),
|
||||
cmd!(noundo "formulas.calculateNow", "Calculate Now", ["Formulas", "Calculation"], Some("F9"), "{}", has_doc, calc_now),
|
||||
cmd!(noundo "formulas.calculateSheet", "Calculate Sheet", ["Formulas", "Calculation"], Some("Shift+F9"), "{}", has_doc, calc_now),
|
||||
cmd!(
|
||||
@@ -229,7 +240,7 @@ fn valid_name(n: &str) -> bool {
|
||||
if !(first.is_alphabetic() || first == '_' || first == '\\') {
|
||||
return false;
|
||||
}
|
||||
if n.len() > 255 || CellRef::parse(n).is_some() || is_r1c1(n) {
|
||||
if n.chars().count() > 255 || CellRef::parse(n).is_some() || is_r1c1(n) {
|
||||
return false;
|
||||
}
|
||||
n.chars().all(|c| c.is_alphanumeric() || matches!(c, '_' | '.' | '\\' | '?'))
|
||||
@@ -237,7 +248,7 @@ fn valid_name(n: &str) -> bool {
|
||||
|
||||
/// Looks like an R1C1 reference (`R`, `C`, `RC`, `R2`, `C3`, `R1C1`), which Excel doesn't
|
||||
/// accept as a name.
|
||||
fn is_r1c1(n: &str) -> bool {
|
||||
pub(crate) fn is_r1c1(n: &str) -> bool {
|
||||
let digits = |s: &str| s.bytes().take_while(u8::is_ascii_digit).count();
|
||||
let mut rest = n;
|
||||
if let Some(r) = rest.strip_prefix(['R', 'r']) {
|
||||
@@ -265,7 +276,7 @@ fn define_name(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let refers = match str_param(p, "refersTo") {
|
||||
// As in Excel, relative references are relative to the active cell; names keep them
|
||||
// relative to A1, as files do.
|
||||
Some(r) => match gridcraft_formula::parse(r.trim_start_matches('=')) {
|
||||
Some(r) => match gridcraft_formula::parse_name(r.trim_start_matches('=')) {
|
||||
Ok(e) if gridcraft_formula::adjust::has_relative_refs(&e) => {
|
||||
let at = d.selection.active;
|
||||
gridcraft_formula::print(&gridcraft_formula::adjust::shift_relative_wrapping(e, -(at.row as i64), -(at.col as i64)))
|
||||
@@ -280,7 +291,7 @@ fn define_name(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), body)
|
||||
}
|
||||
};
|
||||
if gridcraft_formula::parse(&refers).is_err() {
|
||||
if gridcraft_formula::parse_name(&refers).is_err() {
|
||||
return Err(EngineError::Other("There's a problem with this formula.".into()));
|
||||
}
|
||||
let comment = str_param(p, "comment").unwrap_or("").to_string();
|
||||
@@ -348,6 +359,7 @@ fn rename_name_in_formulas(wb: &mut gridcraft_model::Workbook, old: &str, old_sc
|
||||
let Some(e) = f.expr().and_then(rename) else { continue };
|
||||
let mut nf = Formula::from_expr(e);
|
||||
nf.array = f.array;
|
||||
nf.dynamic = f.dynamic;
|
||||
if let Some(cell) = sh.cells.get_mut(c) {
|
||||
cell.formula = Some(std::sync::Arc::new(nf));
|
||||
}
|
||||
@@ -355,7 +367,7 @@ fn rename_name_in_formulas(wb: &mut gridcraft_model::Workbook, old: &str, old_sc
|
||||
}
|
||||
for n in wb.names.iter_mut() {
|
||||
if n.scope.is_none_or(applies)
|
||||
&& let Some(e) = gridcraft_formula::parse(&n.formula).ok().and_then(rename)
|
||||
&& let Some(e) = gridcraft_formula::parse_name(&n.formula).ok().and_then(rename)
|
||||
{
|
||||
n.formula = gridcraft_formula::print(&e);
|
||||
}
|
||||
@@ -389,7 +401,9 @@ fn name_manager(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
.filter(|n| !n.hidden)
|
||||
.map(|n| {
|
||||
let sheet = n.scope.unwrap_or(d.wb.active_sheet);
|
||||
let v = gridcraft_calc::evaluate(&d.wb, sheet, CellRef::default(), &n.formula);
|
||||
// parse_name, not evaluate's parse: a name's top-level commas are unions.
|
||||
let v = gridcraft_formula::parse_name(&n.formula)
|
||||
.map_or(Value::Error(CellError::Name), |e| gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, CellRef::default(), &e));
|
||||
json!({"name": n.name, "refersTo": format!("={}", n.formula), "scope": n.scope.and_then(|i| d.wb.sheet(i)).map(|s| s.name.clone()).unwrap_or_else(|| "Workbook".into()), "value": crate::cmd::inspect::value_json(&v), "comment": n.comment})
|
||||
})
|
||||
.collect();
|
||||
@@ -479,6 +493,82 @@ fn trace_dependents(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Ok(Json::Array(v))
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
use std::collections::BTreeSet;
|
||||
let d = s.doc()?;
|
||||
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
|
||||
let sheet = d.wb.active_sheet;
|
||||
let all = bool_param(p, "all").unwrap_or(false);
|
||||
// (sheet, a1) pairs to select; direct level keeps whole precedent ranges, transitive cells.
|
||||
let mut found: BTreeSet<(usize, String)> = BTreeSet::new();
|
||||
let mut seen: BTreeSet<(usize, CellRef)> = BTreeSet::new();
|
||||
let mut frontier = vec![(sheet, at)];
|
||||
seen.insert((sheet, at));
|
||||
while let Some((ks, kc)) = frontier.pop() {
|
||||
if precedents {
|
||||
for a in d.calc.graph.precedents_of((ks, kc)) {
|
||||
if all {
|
||||
// Walk only the stored cells inside the precedent area (sparse
|
||||
// range iterator, not the whole sheet): blanks are never selected
|
||||
// and only formula cells extend the frontier, so a formula late in
|
||||
// a large range is not skipped.
|
||||
let Some(sh) = d.wb.sheet(a.sheet) else { continue };
|
||||
for (c, cell) in sh.cells.iter_range(a.range) {
|
||||
if matches!(cell.value, Value::Empty) {
|
||||
continue;
|
||||
}
|
||||
if seen.insert((a.sheet, c)) {
|
||||
found.insert((a.sheet, c.a1()));
|
||||
if cell.formula.is_some() {
|
||||
frontier.push((a.sheet, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
found.insert((a.sheet, a.range.a1()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let mut deps = Vec::new();
|
||||
d.calc.graph.dependents(ks, kc, &mut deps);
|
||||
for k2 in deps {
|
||||
if all {
|
||||
if seen.insert(k2) {
|
||||
found.insert((k2.0, k2.1.a1()));
|
||||
frontier.push(k2);
|
||||
}
|
||||
} else {
|
||||
found.insert((k2.0, k2.1.a1()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !all {
|
||||
break;
|
||||
}
|
||||
}
|
||||
found.remove(&(sheet, at.a1()));
|
||||
let name = if precedents { "precedent" } else { "dependent" };
|
||||
let Some(si) = found.iter().find(|(si, _)| *si == sheet).or_else(|| found.first()).map(|(si, _)| *si) else {
|
||||
return Err(bad("cell", format!("No {name} cells.")));
|
||||
};
|
||||
let list: Vec<String> = found.iter().filter(|(s, _)| *s == si).map(|(_, a)| a.clone()).collect();
|
||||
let mut params = json!({ "range": list.join(",") });
|
||||
if si != sheet {
|
||||
params["sheet"] = json!(si);
|
||||
}
|
||||
s.execute("selection.set", params)
|
||||
}
|
||||
|
||||
fn select_precedents(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
select_trace_cells(s, p, true)
|
||||
}
|
||||
|
||||
fn select_dependents(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
select_trace_cells(s, p, false)
|
||||
}
|
||||
|
||||
fn show_formulas(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = s.doc()?.wb.active_sheet;
|
||||
let cur = s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.show_formulas);
|
||||
@@ -489,6 +579,40 @@ fn show_formulas(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The number a cell's text stands for when Excel's background check flags it as a number stored
|
||||
/// as text: a constant (not a formula's result) that would be a number if typed.
|
||||
pub fn number_stored_as_text(cell: &gridcraft_model::Cell) -> Option<f64> {
|
||||
match &cell.value {
|
||||
Value::Text(t) if cell.formula.is_none() => gridcraft_core::parse::number_stored_as_text(t),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert to Number: each number stored as text in the range becomes that number, keeping the
|
||||
/// cell's format.
|
||||
fn convert_to_number(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let ranges = target_ranges(s, p)?;
|
||||
edit(s, |cx| {
|
||||
let mut converted = 0;
|
||||
for r in &ranges {
|
||||
let Some(r) = cx.wb.sheet(sheet).and_then(|sh| sh.used_range()).and_then(|u| r.intersection(&u)) else { continue };
|
||||
let found: Vec<(CellRef, f64)> = cx
|
||||
.wb
|
||||
.sheet(sheet)
|
||||
.map(|sh| sh.cells.iter_range(r).filter_map(|(c, x)| number_stored_as_text(x).map(|n| (c, n))).collect())
|
||||
.unwrap_or_default();
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
for (c, n) in &found {
|
||||
sh.set_value(*c, Value::number(*n));
|
||||
}
|
||||
cx.changed.extend(found.iter().map(|(c, _)| (sheet, *c)));
|
||||
converted += found.len();
|
||||
}
|
||||
Ok(json!({"converted": converted}))
|
||||
})
|
||||
}
|
||||
|
||||
fn error_checking(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let mut out = Vec::new();
|
||||
@@ -528,23 +652,30 @@ fn evaluate_formula(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let mut steps = Vec::new();
|
||||
let mut nodes = Vec::new();
|
||||
expr.walk(&mut |e| {
|
||||
// Borrowed, not cloned: copying every operator of a long chain A1+A2+… copies the
|
||||
// rest of the chain each time (quadratic memory).
|
||||
if matches!(e, gridcraft_formula::Expr::Call(..) | gridcraft_formula::Expr::Binary(..) | gridcraft_formula::Expr::Ref(_)) {
|
||||
nodes.push(e.clone());
|
||||
nodes.push(e);
|
||||
}
|
||||
});
|
||||
for e in nodes.into_iter().rev().take(200) {
|
||||
let v = gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, at, &e);
|
||||
steps.push(json!({"expression": gridcraft_formula::print(&e), "value": crate::cmd::inspect::value_json(&v)}));
|
||||
let v = gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, at, e);
|
||||
steps.push(json!({"expression": gridcraft_formula::print(e), "value": crate::cmd::inspect::value_json(&v)}));
|
||||
}
|
||||
let result = gridcraft_calc::recalc::evaluate_expr(&d.wb, sheet, at, &expr);
|
||||
Ok(json!({"formula": format!("={text}"), "steps": steps, "result": crate::cmd::inspect::value_json(&result)}))
|
||||
}
|
||||
|
||||
/// Evaluates what typing `formula` into a cell would show. A formula `cell.set` would refuse (it
|
||||
/// doesn't parse, or calls a function with the wrong number of arguments) is an error that says
|
||||
/// why, not `#NAME?`; an unknown function name still evaluates to `#NAME?`, as in Excel.
|
||||
fn evaluate(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let f = str_param(p, "formula").ok_or_else(|| bad("formulas.evaluate", "missing `formula`"))?;
|
||||
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
|
||||
let v = gridcraft_calc::evaluate(&d.wb, d.wb.active_sheet, at, f);
|
||||
let expr = crate::cmd::edit::parse_typed_formula(f.strip_prefix('=').unwrap_or(f))?;
|
||||
crate::cmd::edit::check_arg_counts(&expr)?;
|
||||
let v = gridcraft_calc::recalc::evaluate_expr(&d.wb, d.wb.active_sheet, at, &expr);
|
||||
Ok(crate::cmd::inspect::value_json(&v))
|
||||
}
|
||||
|
||||
@@ -610,4 +741,53 @@ mod tests {
|
||||
let r = s.execute("formulas.defineName", json!({"name": "First", "refersTo": "=Sheet1!$A$1"})).unwrap();
|
||||
assert_eq!(r["refersTo"], "=Sheet1!$A$1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_defined_names_count_characters_not_bytes() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let arabic = |n: usize| "س".repeat(n);
|
||||
for ok in [200, 255] {
|
||||
s.execute("formulas.defineName", json!({"name": arabic(ok), "refersTo": "=1"})).unwrap();
|
||||
}
|
||||
assert!(s.execute("formulas.defineName", json!({"name": arabic(256), "refersTo": "=1"})).is_err());
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[format!("ع{}", arabic(199))], [1]]})).unwrap();
|
||||
let r = s.execute("formulas.createFromSelection", json!({"range": "A1:A2", "top": true})).unwrap();
|
||||
assert_eq!(r["created"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_referring_to_several_ranges() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1:C2", "values": [[1, 0, 5], [2, 0, 7]]})).unwrap();
|
||||
// Excel writes and accepts a union in a name without parentheses.
|
||||
let r = s.execute("formulas.defineName", json!({"name": "u", "refersTo": "=Sheet1!$A$1:$A$2,Sheet1!$C$1:$C$2"})).unwrap();
|
||||
assert_eq!(r["refersTo"], "=Sheet1!$A$1:$A$2,Sheet1!$C$1:$C$2");
|
||||
for (cell, input) in [("E1", "=SUM(u)"), ("E2", "=AREAS(u)"), ("E3", "=MIN(u)"), ("E4", "=INDEX(u,2,1,2)")] {
|
||||
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
|
||||
}
|
||||
let v = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["value"].clone();
|
||||
assert_eq!(v(&mut s, "E1"), json!(15.0));
|
||||
assert_eq!(v(&mut s, "E2"), json!(2.0));
|
||||
assert_eq!(v(&mut s, "E3"), json!(1.0));
|
||||
assert_eq!(v(&mut s, "E4"), json!(7.0));
|
||||
// Renaming the sheet follows every area, and the definition keeps Excel's form.
|
||||
s.execute("sheet.rename", json!({"name": "Data"})).unwrap();
|
||||
let name = s.doc().unwrap().wb.names.iter().find(|n| n.name == "u").map(|n| n.formula.clone());
|
||||
assert_eq!(name.as_deref(), Some("Data!$A$1:$A$2,Data!$C$1:$C$2"));
|
||||
assert_eq!(v(&mut s, "E1"), json!(15.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_manager_previews_a_name_over_several_ranges() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1:C2", "values": [[1, 0, 5], [2, 0, 7]]})).unwrap();
|
||||
s.execute("formulas.defineName", json!({"name": "u", "refersTo": "=Sheet1!$A$1:$A$2,Sheet1!$C$1:$C$2"})).unwrap();
|
||||
let list = s.execute("formulas.nameManager", json!({})).unwrap();
|
||||
let u = list.as_array().and_then(|l| l.iter().find(|n| n["name"] == "u")).cloned().unwrap();
|
||||
// The union parses as one, so the preview is what the union gives in a cell, not #NAME?.
|
||||
assert_eq!(u["value"], json!({"error": "#VALUE!"}));
|
||||
}
|
||||
}
|
||||
|
||||
+268
-32
@@ -2,9 +2,11 @@
|
||||
//! conditional formatting (Home › Styles) and symbols.
|
||||
|
||||
use gridcraft_core::{CellRef, MAX_ROWS, RangeRef};
|
||||
use gridcraft_formula::adjust::{Axis, Edit};
|
||||
use gridcraft_model::*;
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
use super::sheet::{rewrite, shift_cell_features};
|
||||
use super::*;
|
||||
use crate::selection::current_region;
|
||||
|
||||
@@ -24,9 +26,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Format as Table",
|
||||
["Home", "Styles"],
|
||||
None,
|
||||
"{range?, style: \"TableStyleMedium2\", header?}",
|
||||
"{range?, style: \"TableStyleMedium2\", header?} (inside a table: restyles it)",
|
||||
has_doc,
|
||||
insert_table
|
||||
format_as_table
|
||||
),
|
||||
cmd!("table.totalRow", "Total Row", ["Table", "Table Style Options"], None, "{table?, on?: bool}", has_doc, |s, p| table_flag(
|
||||
s, p, "totals"
|
||||
@@ -90,7 +92,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Chart Properties",
|
||||
["Chart Design"],
|
||||
None,
|
||||
"{chart?: id, type?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
|
||||
"{chart?: id, type?, title?, legend?: none|bottom|top|left|right, dataLabels?, gridlines?, quartiles?: exclusive|inclusive, style?, xTitle?, yTitle?, at?, width?, height?, dx?, dy?}",
|
||||
has_doc,
|
||||
chart_set
|
||||
),
|
||||
@@ -188,7 +190,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Conditional Formatting",
|
||||
["Home", "Styles"],
|
||||
None,
|
||||
"{range?, rule: {type: cellIs|expression|containsText|beginsWith|endsWith|blanks|noBlanks|errors|noErrors|duplicate|unique|top10|aboveAverage|timePeriod|colorScale|dataBar|iconSet, operator?, value?, value2?, formula?, text?, rank?, bottom?, percent?, below?, period?, colors?: [..], color?, set?, style?: {font..fill..}|preset: lightRedFill|redText|yellowFill|greenFill|redBorder|lightRedFillDarkRedText…}}",
|
||||
"{range?, rule: {type: cellIs|expression|containsText|beginsWith|endsWith|blanks|noBlanks|errors|noErrors|duplicate|unique|top10|aboveAverage|timePeriod|colorScale|dataBar|iconSet, operator?, value?, value2?, formula?, text?, rank?, bottom?, percent?, below?, period?, colors?: [..], color?, set?, style?: {font?: {bold?, italic?, color?}, fill?: \"#FFC7CE\" | {pattern?, fg?, bg?}, borders?, num_fmt?} (colours \"#RRGGBB\") | preset?: lightRedFill|redText|yellowFill|greenFill|redBorder|lightRedFillDarkRedText…}; a text `value` is matched as text, or kept when already a quoted literal}",
|
||||
has_doc,
|
||||
add_cf
|
||||
),
|
||||
@@ -224,6 +226,17 @@ fn block(s: &Session, p: &Json) -> Result<RangeRef> {
|
||||
Ok(current_region(sh, d.selection.active))
|
||||
}
|
||||
|
||||
fn chart_areas(s: &Session, p: &Json) -> Result<Vec<RangeRef>> {
|
||||
if p.get("range").is_some() {
|
||||
return target_ranges(s, p);
|
||||
}
|
||||
let d = s.doc()?;
|
||||
if d.selection.ranges.len() > 1 {
|
||||
return Ok(d.selection.ranges.clone());
|
||||
}
|
||||
Ok(vec![block(s, p)?])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- tables
|
||||
|
||||
fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
@@ -276,13 +289,13 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
return Err(EngineError::Other("To prevent possible loss of data, cells can't be pushed off the end of the worksheet.".into()));
|
||||
}
|
||||
sh.cells.shift_rows_in_cols(r.start.col, r.end.col, r.start.row, 1);
|
||||
sh.cell_pictures = std::mem::take(&mut sh.cell_pictures)
|
||||
.into_iter()
|
||||
.filter_map(|(c, v)| {
|
||||
let dest = if c.col >= r.start.col && c.col <= r.end.col && c.row >= r.start.row { c.offset(1, 0) } else { Some(c) };
|
||||
dest.map(|c| (c, v))
|
||||
})
|
||||
.collect();
|
||||
// Like Insert Cells > Shift down: move merges, conditional formats, notes, links and pictures, and re-point every formula
|
||||
// reference (on any sheet) at the cells that moved.
|
||||
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);
|
||||
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)));
|
||||
}
|
||||
@@ -338,6 +351,21 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Format as Table inside a table restyles that table, as in Excel; elsewhere it makes a new one.
|
||||
fn format_as_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
// Without a range, the active area decides (as in insert_table), not the first one.
|
||||
let r = if p.get("range").is_some() { target_range(s, p)? } else { s.doc()?.selection.current() };
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let inside = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.tables.iter().find(|t| t.range.contains_range(&r))).map(|t| (t.name.clone(), t.range));
|
||||
match inside {
|
||||
Some((table, range)) => {
|
||||
table_style(s, &with(p, "table", json!(table)))?;
|
||||
Ok(json!({"table": table, "range": range.a1()}))
|
||||
}
|
||||
None => insert_table(s, p),
|
||||
}
|
||||
}
|
||||
|
||||
fn find_table(s: &Session, p: &Json) -> Result<(usize, usize)> {
|
||||
let d = s.doc()?;
|
||||
if let Some(n) = str_param(p, "table") {
|
||||
@@ -466,11 +494,13 @@ fn table_style(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
/// Table names follow the Microsoft name rules, as the xlsx writer does: letters, digits, `_` and `.`,
|
||||
/// starting with a letter, `_` or `\`. Anything else (a hyphen, say) would read as an operator inside a
|
||||
/// structured reference, and the writer would rename the table on save.
|
||||
/// starting with a letter, `_` or `\`, and at most 255 characters. Anything else (a hyphen, say) would
|
||||
/// read as an operator inside a structured reference, and the writer would rename the table on save.
|
||||
fn valid_table_name(name: &str) -> bool {
|
||||
name.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == '\\')
|
||||
name.chars().count() <= 255
|
||||
&& name.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == '\\')
|
||||
&& CellRef::parse(name).is_none()
|
||||
&& !super::formulas::is_r1c1(name)
|
||||
&& name.chars().enumerate().all(|(i, c)| c.is_alphanumeric() || c == '_' || c == '.' || (i == 0 && c == '\\'))
|
||||
}
|
||||
|
||||
@@ -480,7 +510,8 @@ fn table_rename(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if !valid_table_name(&name) {
|
||||
return Err(EngineError::Other("The name that you entered is not valid.".into()));
|
||||
}
|
||||
if s.doc()?.wb.table(&name).is_some_and(|x| x != (si, ti)) {
|
||||
let wb = &s.doc()?.wb;
|
||||
if wb.table(&name).is_some_and(|x| x != (si, ti)) || wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(&name)) {
|
||||
return Err(EngineError::Other("The name entered already exists. Enter a unique name.".into()));
|
||||
}
|
||||
edit(s, |cx| {
|
||||
@@ -603,6 +634,7 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
});
|
||||
@@ -618,6 +650,7 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
});
|
||||
@@ -626,6 +659,36 @@ pub fn series_from_range(sh: &Sheet, r: RangeRef, by_rows: bool) -> Vec<Series>
|
||||
out
|
||||
}
|
||||
|
||||
fn series_from_areas(sh: &Sheet, areas: &[RangeRef]) -> Result<(RangeRef, bool, Vec<Series>)> {
|
||||
let Some(first) = areas.first().copied() else { return Err(EngineError::Other("Select data for the chart first.".into())) };
|
||||
let bounds = areas.iter().skip(1).fold(first, |r, area| r.union(area));
|
||||
if areas.len() == 1 {
|
||||
let by_rows = first.width() > first.height() + 1;
|
||||
return Ok((bounds, by_rows, series_from_range(sh, first, by_rows)));
|
||||
}
|
||||
let valid_shape = first.width() == 1
|
||||
&& first.height() > 1
|
||||
&& areas.iter().skip(1).all(|area| area.width() == 1 && area.height() == first.height())
|
||||
&& first.iter().all(|cell| sh.value(cell).is_text() || sh.value(cell).is_empty());
|
||||
if !valid_shape {
|
||||
return Err(bad("insert.chart", "multi-area charts require a text category column followed by equal-height series columns"));
|
||||
}
|
||||
let category = series_from_range(sh, first, false).into_iter().next().map(|series| series.values);
|
||||
let mut series = Vec::with_capacity(areas.len() - 1);
|
||||
for area in &areas[1..] {
|
||||
let mut area_series = series_from_range(sh, *area, false);
|
||||
let Some(mut item) = area_series.pop() else {
|
||||
return Err(EngineError::Other("Select data for the chart first.".into()));
|
||||
};
|
||||
if item.name.is_none() {
|
||||
return Err(bad("insert.chart", "multi-area series columns require headers"));
|
||||
}
|
||||
item.categories = category.clone();
|
||||
series.push(item);
|
||||
}
|
||||
Ok((bounds, false, series))
|
||||
}
|
||||
|
||||
/// A scatter plot of a block without category labels takes its first column (or row) as the X
|
||||
/// values of the others, as Excel does: two plain columns make one series of points, not two (#428).
|
||||
pub fn scatter_x_from_first(kind: ChartKind, series: &mut Vec<Series>) {
|
||||
@@ -646,15 +709,14 @@ const MAX_CHART_CELLS: u64 = 1_000_000;
|
||||
/// without labels → scatter; one short series → column, pie, bar; long series → line; several
|
||||
/// series → clustered and stacked columns and bars, line.
|
||||
pub fn recommend_charts(s: &Session, p: &Json) -> Result<Vec<(&'static str, &'static str, &'static str)>> {
|
||||
let r = block(s, p)?;
|
||||
if r.count() > MAX_CHART_CELLS {
|
||||
let areas = chart_areas(s, p)?;
|
||||
if areas.iter().fold(0_u64, |count, area| count.saturating_add(area.count())) > MAX_CHART_CELLS {
|
||||
return Err(bad("insert.chart", "range too large for a chart"));
|
||||
}
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
let by_rows = r.width() > r.height() + 1;
|
||||
let series = series_from_range(sh, r, by_rows);
|
||||
let (_, by_rows, series) = series_from_areas(sh, &areas)?;
|
||||
let ranges: Vec<RangeRef> = series
|
||||
.iter()
|
||||
.filter_map(|x| match gridcraft_formula::parse(&x.values) {
|
||||
@@ -706,8 +768,8 @@ pub fn recommend_charts(s: &Session, p: &Json) -> Result<Vec<(&'static str, &'st
|
||||
/// The chart `insert.chart` would insert for these params, without inserting it: the sheet it
|
||||
/// goes on and the chart (previews in the Recommended Charts dialog).
|
||||
pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
|
||||
let r = block(s, p)?;
|
||||
if r.count() > MAX_CHART_CELLS {
|
||||
let areas = chart_areas(s, p)?;
|
||||
if areas.iter().fold(0_u64, |count, area| count.saturating_add(area.count())) > MAX_CHART_CELLS {
|
||||
return Err(bad("insert.chart", "range too large for a chart"));
|
||||
}
|
||||
let mut t = str_param(p, "type").unwrap_or("column").to_string();
|
||||
@@ -720,18 +782,14 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
// Excel plots series by the longer dimension (more rows → series in columns).
|
||||
let data_h = r.height();
|
||||
let data_w = r.width();
|
||||
let by_rows = data_w > data_h + 1;
|
||||
let kind = chart_kind(&t, &sub);
|
||||
let mut series = series_from_range(sh, r, by_rows);
|
||||
let (bounds, by_rows, mut series) = series_from_areas(sh, &areas)?;
|
||||
if series.is_empty() {
|
||||
return Err(EngineError::Other("Select data for the chart first.".into()));
|
||||
}
|
||||
scatter_x_from_first(kind, &mut series);
|
||||
let id = d.wb.next_object_id();
|
||||
let at = cell_param_on(s, p, "at", sheet)?.unwrap_or_else(|| CellRef::new(r.start.row, r.end.col + 2));
|
||||
let at = cell_param_on(s, p, "at", sheet)?.unwrap_or_else(|| CellRef::new(bounds.start.row, bounds.end.col + 2));
|
||||
let title = str_param(p, "title").map(str::to_string).or_else(|| {
|
||||
if series.len() == 1 {
|
||||
series[0].name.as_ref().map(|n| gridcraft_calc::evaluate(&d.wb, sheet, at, n).display())
|
||||
@@ -758,7 +816,7 @@ pub fn build_chart(s: &Session, p: &Json) -> Result<(usize, Chart)> {
|
||||
style: 1,
|
||||
x_title: None,
|
||||
y_title: None,
|
||||
source: Some(format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), r.a1())),
|
||||
source: (areas.len() == 1).then(|| format!("{}!{}", gridcraft_formula::quote_sheet(&sh.name), bounds.a1())),
|
||||
by_rows,
|
||||
pivot: None,
|
||||
};
|
||||
@@ -788,12 +846,25 @@ fn find_chart(s: &Session, p: &Json) -> Result<(usize, usize)> {
|
||||
fn chart_set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (si, ci) = find_chart(s, p)?;
|
||||
let at = cell_param_on(s, p, "at", si)?;
|
||||
let quartiles = p
|
||||
.get("quartiles")
|
||||
.map(|v| match v.as_str() {
|
||||
Some("exclusive") => Ok(gridcraft_model::QuartileMethod::Exclusive),
|
||||
Some("inclusive") => Ok(gridcraft_model::QuartileMethod::Inclusive),
|
||||
_ => Err(bad("quartiles", "expected exclusive or inclusive")),
|
||||
})
|
||||
.transpose()?;
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
let Some(c) = sh.charts.get_mut(ci) else { return Ok(Json::Null) };
|
||||
if let Some(t) = str_param(p, "type") {
|
||||
c.kind = chart_kind(t, str_param(p, "subtype").unwrap_or(""));
|
||||
}
|
||||
if let Some(method) = quartiles {
|
||||
for series in &mut c.series {
|
||||
series.quartiles = method;
|
||||
}
|
||||
}
|
||||
if let Some(t) = p.get("title") {
|
||||
c.title = t.as_str().map(str::to_string);
|
||||
}
|
||||
@@ -977,7 +1048,7 @@ fn insert_picture(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
// Excel inserts pictures as "Move but don't size with cells" (`editAs="oneCell"`).
|
||||
mode: anchor_mode_param(p).unwrap_or(gridcraft_model::AnchorMode::MoveOnly),
|
||||
},
|
||||
data,
|
||||
data: data.into(),
|
||||
mime,
|
||||
alt: str_param(p, "alt").unwrap_or("").into(),
|
||||
};
|
||||
@@ -1249,7 +1320,16 @@ fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result<Json> {
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?.comments.insert(
|
||||
at,
|
||||
Comment { author: author.clone(), text: text.clone(), replies: vec![], threaded, resolved: false, visible: false, dates: vec![] },
|
||||
Comment {
|
||||
author: author.clone(),
|
||||
text: text.clone(),
|
||||
replies: vec![],
|
||||
threaded,
|
||||
resolved: false,
|
||||
visible: false,
|
||||
dates: vec![],
|
||||
note_box: None,
|
||||
},
|
||||
);
|
||||
Ok(json!({"cell": at.a1()}))
|
||||
})
|
||||
@@ -1334,6 +1414,110 @@ fn preset_style(name: &str) -> Style {
|
||||
s
|
||||
}
|
||||
|
||||
/// Whether `v` is one formula string literal: `"…"` with every inner quote doubled.
|
||||
fn is_string_literal(v: &str) -> bool {
|
||||
v.len() >= 2 && v.starts_with('"') && v.ends_with('"') && v.get(1..v.len() - 1).is_some_and(|inner| !inner.replace("\"\"", "").contains('"'))
|
||||
}
|
||||
|
||||
/// A conditional format's `style` as the model's `Style` JSON (`font`, `fill`, `borders`, `align`,
|
||||
/// `num_fmt`, `protection`, merged over the default by the caller), where a colour may also be
|
||||
/// written as `format_range` takes it (`"#RRGGBB"`, `{theme, tint}`, `"auto"`) and `fill` may be a
|
||||
/// colour alone (a solid fill) or `"none"` (no fill). An unknown key is an error rather than
|
||||
/// dropped: dropped, it left a rule with an empty format that matches and shows nothing.
|
||||
fn cf_style_json(st: &Json) -> Result<Json> {
|
||||
const KEYS: [&str; 6] = ["font", "fill", "borders", "align", "num_fmt", "protection"];
|
||||
let err = |m: String| bad("home.conditionalFormat", m);
|
||||
let Some(obj) = st.as_object() else { return Err(err("`style` must be an object, e.g. {\"fill\": \"#FFC7CE\"}".into())) };
|
||||
let mut st = obj.clone();
|
||||
if let Some(k) = st.keys().find(|k| !KEYS.contains(&k.as_str())) {
|
||||
let hint = if k == "preset" { "; a preset goes in `rule.preset`" } else { "" };
|
||||
return Err(err(format!("unknown key `{k}` in `style` (known: {}){hint}", KEYS.join(", "))));
|
||||
}
|
||||
// A colour written the way the other formatting commands take it, as the model stores it.
|
||||
let color = |v: &mut Json, what: &str| -> Result<()> {
|
||||
if v.is_string() || v.get("theme").is_some() {
|
||||
let c = super::format::color_param(Some(v)).ok_or_else(|| err(format!("invalid colour {v} for `{what}`")))?;
|
||||
*v = serde_json::to_value(c).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
if let Some(f) = st.get_mut("font").and_then(Json::as_object_mut)
|
||||
&& let Some(c) = f.get_mut("color")
|
||||
{
|
||||
color(c, "font.color")?;
|
||||
}
|
||||
if let Some(fill) = st.get_mut("fill") {
|
||||
// `"none"` (or `"auto"`) is no fill, as `home.fillColor` reads it, not a solid automatic one.
|
||||
if fill.is_string() && super::format::color_param(Some(fill)) == Some(Color::Auto) {
|
||||
*fill = serde_json::to_value(Fill::default()).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
} else if fill.is_string() || fill.get("theme").is_some() {
|
||||
*fill = json!({"pattern": "Solid", "fg": fill.clone()});
|
||||
}
|
||||
if let Some(f) = fill.as_object_mut() {
|
||||
for k in ["fg", "bg"] {
|
||||
if let Some(c) = f.get_mut(k) {
|
||||
color(c, &format!("fill.{k}"))?;
|
||||
}
|
||||
}
|
||||
// A colour with no pattern is a solid fill, not an invisible one.
|
||||
if f.contains_key("fg") && !f.contains_key("pattern") {
|
||||
f.insert("pattern".into(), json!("Solid"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(b) = st.get_mut("borders").and_then(Json::as_object_mut) {
|
||||
for (side, line) in b.iter_mut() {
|
||||
if let Some(c) = line.get_mut("color") {
|
||||
color(c, &format!("borders.{side}.color"))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Json::Object(st))
|
||||
}
|
||||
|
||||
/// #1999: a formula that doesn't parse must never reach the workbook — Excel refuses to open
|
||||
/// files carrying it, even in repair mode. Validated here so the API and the New Rule dialog
|
||||
/// (which dispatches this command) share one gate, the way Excel's object model refuses.
|
||||
fn check_cf_formula(cmd: &str, what: &str, formula: &str) -> Result<()> {
|
||||
// Strip exactly one leading '=', the way the XLSX writer does: a value like '===A1'
|
||||
// only parses after several strips and would still be saved unparseable. (#1999 review)
|
||||
if gridcraft_formula::parse(formula.strip_prefix('=').unwrap_or(formula)).is_err() {
|
||||
return Err(bad(cmd, format!("invalid {what} formula `{formula}` — it does not parse; Excel would reject the saved file")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_cf_value(what: &str, v: &gridcraft_model::features::CfValueKind) -> Result<()> {
|
||||
if let gridcraft_model::features::CfValueKind::Formula(f) = v {
|
||||
check_cf_formula("home.conditionalFormat", what, f)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_cf_rule(rule: &CfRule) -> Result<()> {
|
||||
const CMD: &str = "home.conditionalFormat";
|
||||
match rule {
|
||||
CfRule::CellIs { a, b, .. } => {
|
||||
check_cf_formula(CMD, "comparison value", a)?;
|
||||
if let Some(b) = b {
|
||||
check_cf_formula(CMD, "second comparison value", b)?;
|
||||
}
|
||||
}
|
||||
CfRule::Expression { formula, .. } => check_cf_formula(CMD, "rule", formula)?,
|
||||
CfRule::DataBar { min, max, .. } => {
|
||||
check_cf_value("data bar minimum", min)?;
|
||||
check_cf_value("data bar maximum", max)?;
|
||||
}
|
||||
CfRule::ColorScale { stops } => {
|
||||
for (kind, _) in stops {
|
||||
check_cf_value("color scale threshold", kind)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_cf(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let ranges = target_ranges(s, p)?;
|
||||
let sheet = target_sheet(s, p)?;
|
||||
@@ -1343,9 +1527,10 @@ fn add_cf(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
};
|
||||
let g = |k: &str| rule.get(k).and_then(Json::as_str).map(str::to_string);
|
||||
let style = Box::new(match rule.get("style") {
|
||||
Some(st) if st.is_object() => {
|
||||
Some(st) if !st.is_null() => {
|
||||
let st = cf_style_json(st)?;
|
||||
let base = serde_json::to_value(Style::default()).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
serde_json::from_value(super::format::merge_json(base, st)).map_err(|e| bad("home.conditionalFormat", e.to_string()))?
|
||||
serde_json::from_value(super::format::merge_json(base, &st)).map_err(|e| bad("home.conditionalFormat", e.to_string()))?
|
||||
}
|
||||
_ => preset_style(g("preset").as_deref().unwrap_or("lightRedFillDarkRedText")),
|
||||
});
|
||||
@@ -1384,8 +1569,12 @@ fn add_cf(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let a = rule.get("value").map(super::edit::json_to_input).unwrap_or_default();
|
||||
let b = rule.get("value2").map(super::edit::json_to_input);
|
||||
let quote = |v: String| {
|
||||
// A number, a formula, or text already written as a string literal (`"Closed"`, as
|
||||
// in the file and in openpyxl's `CellIsRule`) is kept; other text is quoted.
|
||||
if gridcraft_core::parse::parse_number_text(&v).is_some() || v.starts_with('=') {
|
||||
v.trim_start_matches('=').to_string()
|
||||
} else if is_string_literal(&v) {
|
||||
v
|
||||
} else {
|
||||
format!("\"{}\"", v.replace('"', "\"\""))
|
||||
}
|
||||
@@ -1442,6 +1631,7 @@ fn add_cf(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
},
|
||||
other => return Err(bad("home.conditionalFormat", format!("unknown rule type `{other}`"))),
|
||||
};
|
||||
check_cf_rule(&rule)?;
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
let priority = sh.cond_formats.iter().map(|c| c.priority).max().unwrap_or(0) + 1;
|
||||
@@ -1553,4 +1743,50 @@ mod tests {
|
||||
let rules = s.execute("home.manageRules", json!({})).unwrap();
|
||||
assert_eq!(rules["rules"].as_array().map(Vec::len), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_without_header_keeps_outside_references() {
|
||||
// #1030: the data moved down for the new header row, but formulas outside kept the old addresses.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("home.insertSheet", json!({"name": "Two"})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "10"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "20"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "=SUM(A1:A2)"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C1", "input": "=A2"})).unwrap();
|
||||
s.execute("cell.set", json!({"sheet": "Two", "cell": "A1", "input": "=Sheet1!A2"})).unwrap();
|
||||
s.execute("insert.table", json!({"range": "A1:A2", "header": false})).unwrap();
|
||||
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
|
||||
assert_eq!(get(&mut s, "A1")["value"], "Column1");
|
||||
assert_eq!(get(&mut s, "A2")["value"], json!(10.0));
|
||||
assert_eq!(get(&mut s, "B1")["formula"], "=SUM(A2:A3)");
|
||||
assert_eq!(get(&mut s, "B1")["value"], json!(30.0));
|
||||
assert_eq!(get(&mut s, "C1")["formula"], "=A3");
|
||||
assert_eq!(get(&mut s, "C1")["value"], json!(20.0));
|
||||
let other = s.execute("cell.get", json!({"sheet": "Two", "cell": "A1"})).unwrap();
|
||||
assert_eq!(other["formula"], "=Sheet1!A3");
|
||||
assert_eq!(other["value"], json!(20.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_names_refuse_defined_names_and_r1c1() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1:A2", "values": [["h"], ["1"]]})).unwrap();
|
||||
s.execute("formulas.defineName", json!({"name": "Rate", "refersTo": "=0.2"})).unwrap();
|
||||
s.execute("insert.table", json!({"range": "A1:A2"})).unwrap();
|
||||
let e = s.execute("table.rename", json!({"table": "Table1", "name": "Rate"})).unwrap_err();
|
||||
assert!(e.to_string().contains("already exists"), "{e}");
|
||||
let e = s.execute("table.rename", json!({"table": "Table1", "name": "rate"})).unwrap_err();
|
||||
assert!(e.to_string().contains("already exists"), "{e}");
|
||||
for n in ["R2C3", "c", "r", "RC", "C5"] {
|
||||
let e = s.execute("table.rename", json!({"table": "Table1", "name": n})).unwrap_err();
|
||||
assert!(e.to_string().contains("not valid"), "{n}: {e}");
|
||||
let e = s.execute("insert.table", json!({"range": "C1:C2", "name": n})).unwrap_err();
|
||||
assert!(e.to_string().contains("not valid"), "{n}: {e}");
|
||||
}
|
||||
s.execute("table.rename", json!({"table": "Table1", "name": "Table1"})).unwrap();
|
||||
s.execute("table.rename", json!({"table": "Table1", "name": "Rates"})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ fn cell_get(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
"text": crate::display::cell_text(&d.wb, sh, c),
|
||||
"input": sh.input_text(c),
|
||||
"picture": sh.cell_pictures.get(&c).map(|picture| json!({"mime": picture.mime, "alt": picture.alt, "bytes": picture.data.len()})),
|
||||
"spilledFrom": sh.spill_ranges.iter().find(|(a, r)| **a != c && r.contains(c)).map(|(a, _)| a.a1()),
|
||||
"spilledFrom": sh.spill_anchor(c).map(|a| a.a1()),
|
||||
"spill": sh.spill_ranges.get(&c).map(|r| r.a1()),
|
||||
"style": style,
|
||||
"merge": sh.merge_at(c).map(|m| m.a1()),
|
||||
|
||||
@@ -246,6 +246,11 @@ pub(crate) fn target_ranges(s: &Session, p: &Json) -> Result<Vec<RangeRef>> {
|
||||
Ok(s.doc()?.selection.ranges.clone())
|
||||
}
|
||||
|
||||
/// A `rows`/`cols` parameter: `3:5` and `B:C`, or a single `3` or `B`.
|
||||
pub(crate) fn parse_lines(t: &str) -> Option<RangeRef> {
|
||||
RangeRef::parse(t).or_else(|| if t.contains(':') { None } else { RangeRef::parse(&format!("{t}:{t}")) })
|
||||
}
|
||||
|
||||
pub(crate) fn target_range(s: &Session, p: &Json) -> Result<RangeRef> {
|
||||
Ok(target_ranges(s, p)?.first().copied().unwrap_or_default())
|
||||
}
|
||||
@@ -269,13 +274,7 @@ pub(crate) fn target_sheet(s: &Session, p: &Json) -> Result<usize> {
|
||||
Ok(d.wb.active_sheet)
|
||||
}
|
||||
|
||||
/// `'My Sheet'!A1` → (`Some("My Sheet")`, `A1`).
|
||||
pub(crate) fn split_sheet(r: &str) -> (Option<String>, &str) {
|
||||
match r.rsplit_once('!') {
|
||||
Some((sh, body)) => (Some(sh.trim_matches('\'').replace("''", "'")), body),
|
||||
None => (None, r),
|
||||
}
|
||||
}
|
||||
pub(crate) use gridcraft_core::split_sheet;
|
||||
|
||||
/// A single-cell parameter. Absent or null is `Ok(None)` (callers default it); a value that is not
|
||||
/// a cell reference, or that names a sheet other than the active one, is an error rather than a
|
||||
@@ -394,6 +393,18 @@ fn check_protection(old: &Workbook, new: &Workbook, unlocked: &[(String, String)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refuses an action a protected sheet does not allow, by the tick box for it in Review ▸ Protect
|
||||
/// Sheet. `what` names the action in the message. An unprotected sheet allows everything.
|
||||
///
|
||||
/// Cell edits are guarded separately, by whether the cell is locked (`locked_cell_error`): these
|
||||
/// permissions are the ones that are not about one cell's contents.
|
||||
pub(crate) fn refuse_unless_allowed(s: &Session, sheet: usize, what: &str, pick: fn(&gridcraft_model::SheetProtection) -> bool) -> Result<()> {
|
||||
if s.doc()?.wb.sheet(sheet).is_some_and(|sh| sh.protection.as_ref().is_some_and(|pr| !pick(pr))) {
|
||||
return Err(EngineError::Other(format!("{what} isn't allowed on this protected sheet (Review › Unprotect Sheet).")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether a locked cell on a protected sheet stays locked: `None` when it's in an Allow Edit
|
||||
/// Range without a password or one unlocked in this session, else the message to show.
|
||||
pub(crate) fn locked_cell_error(sh: &gridcraft_model::Sheet, c: CellRef, unlocked: &[(String, String)]) -> Option<String> {
|
||||
@@ -453,18 +464,53 @@ fn same_content(a: Option<&gridcraft_model::Cell>, b: Option<&gridcraft_model::C
|
||||
}
|
||||
}
|
||||
|
||||
/// Recomputes an automatic row height from its content: the largest font, and wrapped or
|
||||
/// multi-line text. Rows with a user-set height keep it.
|
||||
/// Lines a single text line needs when word-wrapped into `width` points. The grid wraps whole
|
||||
/// words (egui's LayoutJob), so dividing the total width by the column width undercounts: each
|
||||
/// drawn line ends early at the last word that fits. A word wider than the column breaks
|
||||
/// mid-word, the way egui does. (#1969)
|
||||
fn wrapped_line_count(line: &str, width: f32, size: f32, bold: bool) -> f32 {
|
||||
let word_w = |w: &str| crate::cmd::format::approx_text_width(w, size, bold);
|
||||
let space_w = word_w(" ");
|
||||
let mut lines = 1.0f32;
|
||||
let mut used = 0.0f32;
|
||||
for word in line.split_whitespace() {
|
||||
let w = word_w(word);
|
||||
if w > width {
|
||||
// wider than the column: egui fills the current line, then breaks it across lines
|
||||
if used > 0.0 {
|
||||
lines += 1.0;
|
||||
}
|
||||
let chunks = (w / width).ceil();
|
||||
lines += chunks - 1.0;
|
||||
used = w - (chunks - 1.0) * width;
|
||||
continue;
|
||||
}
|
||||
let gap = if used > 0.0 { space_w } else { 0.0 };
|
||||
if used + gap + w > width {
|
||||
lines += 1.0;
|
||||
used = w;
|
||||
} else {
|
||||
used += gap + w;
|
||||
}
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// Recomputes an automatic row height from its content: the largest font, and wrapped text.
|
||||
/// Rows with a user-set height keep it.
|
||||
///
|
||||
/// Line breaks in a cell's text count only when the cell wraps, as in Excel: with Wrap Text off
|
||||
/// a multi-line value shows on one line and AutoFit gives a one-line row.
|
||||
pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) {
|
||||
let Some(sh) = wb.sheet(si) else { return };
|
||||
if sh.rows.get(&row).is_some_and(|i| i.custom) {
|
||||
return;
|
||||
}
|
||||
let default = sh.default_row_height;
|
||||
// Fast path: rows of default-size, unwrapped, single-line content keep the default height.
|
||||
// Fast path: rows of default-size, unwrapped content keep the default height.
|
||||
let plain = sh.cells.row(row, 0, gridcraft_core::MAX_COLS - 1).all(|(_, cell)| {
|
||||
let st = wb.styles.get(cell.style);
|
||||
st.font.size <= gridcraft_model::DEFAULT_FONT_SIZE && !st.align.wrap && !cell.value.as_text().is_some_and(|t| t.contains('\n'))
|
||||
st.font.size <= gridcraft_model::DEFAULT_FONT_SIZE && !st.align.wrap
|
||||
});
|
||||
if plain {
|
||||
if sh.rows.get(&row).is_some_and(|i| i.size.is_some())
|
||||
@@ -482,22 +528,24 @@ pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) {
|
||||
}
|
||||
let mut need: f32 = default;
|
||||
for (c, cell) in sh.cells.row(row, 0, gridcraft_core::MAX_COLS - 1) {
|
||||
// Merged cells never auto-fit their rows, like Excel (column autofit skips them too).
|
||||
if sh.merge_at(CellRef::new(row, c)).is_some_and(|m| !m.is_single()) {
|
||||
continue;
|
||||
}
|
||||
let st = wb.styles.get(cell.style);
|
||||
let size = st.font.size;
|
||||
let line = (size * 1.8).round().max(default * size / gridcraft_model::DEFAULT_FONT_SIZE);
|
||||
// Scaled from an 11pt row's height, not the sheet's default: a sheet whose default
|
||||
// already fits a bigger font (21pt rows for 16pt text) would otherwise scale it twice.
|
||||
let line = (size * 1.8).round().max(gridcraft_model::DEFAULT_ROW_HEIGHT * size / gridcraft_model::DEFAULT_FONT_SIZE);
|
||||
let mut lines = 1.0f32;
|
||||
if !cell.value.is_empty() || cell.formula.is_some() {
|
||||
let text = crate::display::cell_text(wb, sh, CellRef::new(row, c));
|
||||
let explicit = text.lines().count().max(1) as f32;
|
||||
lines = if st.align.wrap {
|
||||
// Only a wrapping cell is laid out over several lines: each explicit line break
|
||||
// starts one, and each line folds at the column width.
|
||||
if st.align.wrap {
|
||||
let text = crate::display::cell_text(wb, sh, CellRef::new(row, c));
|
||||
let w = sh.col_width(c).max(1.0) - 6.0;
|
||||
text.lines()
|
||||
.map(|l| (crate::cmd::format::approx_text_width(l, size, st.font.bold) / w.max(1.0)).ceil().max(1.0))
|
||||
.sum::<f32>()
|
||||
.max(1.0)
|
||||
} else {
|
||||
explicit
|
||||
};
|
||||
lines = text.lines().map(|l| wrapped_line_count(l, w.max(1.0), size, st.font.bold)).sum::<f32>().max(1.0);
|
||||
}
|
||||
} else if st.font.size <= gridcraft_model::DEFAULT_FONT_SIZE {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -675,7 +675,13 @@ fn insert_pivot(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Json::String(n) => add_to_area("insert.pivotTable", pt, &src, n, area, None, None, None)?,
|
||||
Json::Object(_) => {
|
||||
let n = str_param(f, "field").ok_or_else(|| bad("insert.pivotTable", "a field object needs `field`"))?;
|
||||
add_to_area("insert.pivotTable", pt, &src, n, area, func_param(str_param(f, "func")), str_param(f, "name"), None)?;
|
||||
let func = match str_param(f, "func") {
|
||||
Some(fname) => {
|
||||
Some(func_param(Some(fname)).ok_or_else(|| bad("insert.pivotTable", format!("unknown function `{fname}`")))?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
add_to_area("insert.pivotTable", pt, &src, n, area, func, str_param(f, "name"), None)?;
|
||||
let by = group_param(str_param(f, "group"));
|
||||
if let Some(by) = by.filter(|b| *b != PivotDateGroup::None)
|
||||
&& matches!(area, "rows" | "columns")
|
||||
@@ -1386,7 +1392,7 @@ fn chart_pivot(s: &Session, p: &Json) -> Result<(usize, usize)> {
|
||||
let (sheet, addr) = match t.rsplit_once('!') {
|
||||
Some((sh, a)) => {
|
||||
let sh = sh.trim();
|
||||
let sh = sh.strip_prefix('\'').and_then(|x| x.strip_suffix('\'')).map(|x| x.replace("''", "'")).unwrap_or_else(|| sh.to_string());
|
||||
let sh = gridcraft_core::unquote_sheet(sh);
|
||||
(d.wb.sheet_index(&sh), a)
|
||||
}
|
||||
None => (Some(d.wb.active_sheet), t),
|
||||
@@ -2308,4 +2314,53 @@ mod tests {
|
||||
// 64 Sub items under the collapsed "g" show as one column group: (1 + grand total) x 256 columns fit.
|
||||
s.execute("pivot.addField", json!({"field": "Sub", "area": "columns"})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserting_or_deleting_lines_moves_the_pivot_with_its_cells() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:E13", "destination": "Sheet1!H10", "rows": ["Region"], "values": ["Sales"]}))
|
||||
.unwrap();
|
||||
assert_eq!(pt(&s).last_range.unwrap().a1(), "H10:I14");
|
||||
// Rows above the report (and below the source) move the report only.
|
||||
s.execute("home.insertRows", json!({"rows": "16:17"})).unwrap();
|
||||
s.execute("home.insertRows", json!({"rows": "6:7"})).unwrap();
|
||||
assert_eq!(text(&s, "H12"), "Row Labels");
|
||||
let l = s.execute("pivot.list", json!({})).unwrap();
|
||||
assert_eq!(l[0]["anchor"], "H12");
|
||||
assert_eq!(l[0]["range"], "H12:I16");
|
||||
assert_eq!(l[0]["source"], "Sheet1!A1:E15");
|
||||
// A row above the source moves both.
|
||||
s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap();
|
||||
let p = pt(&s);
|
||||
assert_eq!(p.source, "Sheet1!A2:E16");
|
||||
assert_eq!(p.anchor.a1(), "H13");
|
||||
s.execute("pivot.refresh", json!({})).unwrap();
|
||||
assert_eq!(pt(&s).last_range.unwrap().a1(), "H13:I17");
|
||||
assert_eq!(text(&s, "H13"), "Row Labels");
|
||||
assert_eq!(val(&s, "I17"), Value::Number(TOTAL));
|
||||
// Deleting a column left of the report moves it left.
|
||||
s.execute("home.deleteColumns", json!({"cols": "F:F"})).unwrap();
|
||||
let p = pt(&s);
|
||||
assert_eq!(p.anchor.a1(), "G13");
|
||||
assert_eq!(p.last_range.unwrap().a1(), "G13:H17");
|
||||
// Deleting rows above moves it up.
|
||||
s.execute("home.deleteRows", json!({"rows": "1:1"})).unwrap();
|
||||
assert_eq!(pt(&s).source, "Sheet1!A1:E15");
|
||||
assert_eq!(pt(&s).anchor.a1(), "G12");
|
||||
s.execute("pivot.refresh", json!({})).unwrap();
|
||||
assert_eq!(text(&s, "G12"), "Row Labels");
|
||||
assert_eq!(val(&s, "H16"), Value::Number(TOTAL));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserting_rows_on_the_source_sheet_moves_another_sheets_pivot_source() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:E13", "rows": ["Region"], "values": ["Sales"]})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": "Sheet1"})).unwrap();
|
||||
s.execute("home.insertRows", json!({"rows": "1:2"})).unwrap();
|
||||
let l = s.execute("pivot.list", json!({})).unwrap();
|
||||
assert_eq!(l[0]["source"], "Sheet1!A3:E15");
|
||||
assert_eq!(l[0]["anchor"], "A3");
|
||||
s.execute("pivot.refresh", json!({"pivot": "PivotTable1"})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
+231
-21
@@ -50,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Page Setup…",
|
||||
["Page Layout", "Page Setup"],
|
||||
None,
|
||||
"{sheet?, orientation?: portrait|landscape, paper?: Letter|Legal|A4|A3|A5|Tabloid|Executive|Ledger|B5, 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\"]} → the sheet's print settings",
|
||||
has_doc,
|
||||
page_setup
|
||||
),
|
||||
@@ -71,19 +71,36 @@ pub fn paper_size(name: &str) -> (f32, f32) {
|
||||
known_paper(name).unwrap_or((612.0, 792.0))
|
||||
}
|
||||
|
||||
/// Paper sizes in points, portrait. `B4`/`B5`/`B6` are the **JIS** sizes Japanese Excel and its
|
||||
/// e-mail attachments use (257×364, 182×257 and 128×182 mm); the ISO B series stays reachable as
|
||||
/// `B4 (ISO)`/`B5 (ISO)`/`B6 (ISO)`. The Japanese postcards and envelopes are the JIS ones too.
|
||||
fn known_paper(name: &str) -> Option<(f32, f32)> {
|
||||
let n = name.trim().to_ascii_lowercase().replace([' ', '-', '_'], "");
|
||||
// Spaces, hyphens, underscores and parentheses don't matter: `B5 (JIS)`, `b5-jis` and `b5`
|
||||
// are one name.
|
||||
let n = name.trim().to_ascii_lowercase().replace([' ', '-', '_', '(', ')'], "");
|
||||
Some(match n.as_str() {
|
||||
"letter" | "usletter" => (612.0, 792.0),
|
||||
"legal" | "uslegal" => (612.0, 1008.0),
|
||||
"a4" => (595.28, 841.89),
|
||||
"a3" => (841.89, 1190.55),
|
||||
"a5" => (419.53, 595.28),
|
||||
"a6" => (297.64, 419.53),
|
||||
"tabloid" | "11x17" => (792.0, 1224.0),
|
||||
"ledger" => (1224.0, 792.0),
|
||||
"executive" => (522.0, 756.0),
|
||||
"b5" => (498.9, 708.66),
|
||||
"b4" => (708.66, 1000.63),
|
||||
"b4" | "b4jis" => (728.50, 1031.81),
|
||||
"b5" | "b5jis" => (515.91, 728.50),
|
||||
"b6" | "b6jis" => (362.83, 515.91),
|
||||
"b4iso" => (708.66, 1000.63),
|
||||
"b5iso" => (498.90, 708.66),
|
||||
"b6iso" => (354.33, 498.90),
|
||||
"japanesepostcard" | "postcard" => (283.46, 419.53),
|
||||
"japanesedoublepostcard" | "doublepostcard" => (419.53, 566.93),
|
||||
"japaneseenvelope#3" | "chou#3" => (340.16, 666.14),
|
||||
// 長形4号 is 90×205 mm; 90×225 mm is 長形40号, a different envelope, so it stays out.
|
||||
"japaneseenvelope#4" | "chou#4" => (255.12, 581.10),
|
||||
"japaneseenvelopekaku#2" | "kaku#2" => (680.31, 941.10),
|
||||
"japaneseenvelopekaku#3" | "kaku#3" => (612.28, 785.20),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -604,20 +621,6 @@ fn rgb_of(c: Color, wb: &Workbook) -> Option<Rgb> {
|
||||
c.resolve(&wb.theme)
|
||||
}
|
||||
|
||||
fn numfmt_rgb(c: gridcraft_numfmt::FormatColor) -> Rgb {
|
||||
use gridcraft_numfmt::FormatColor as F;
|
||||
match c {
|
||||
F::Black | F::Indexed(_) => [0, 0, 0],
|
||||
F::Blue => [0, 0, 255],
|
||||
F::Cyan => [0, 255, 255],
|
||||
F::Green => [0, 128, 0],
|
||||
F::Magenta => [255, 0, 255],
|
||||
F::Red => [255, 0, 0],
|
||||
F::White => [255, 255, 255],
|
||||
F::Yellow => [255, 255, 0],
|
||||
}
|
||||
}
|
||||
|
||||
fn blend(fg: [u8; 4]) -> Rgb {
|
||||
let a = fg[3] as u16;
|
||||
let mix = |c: u8| ((c as u16 * a + 255 * (255 - a)) / 255) as u8;
|
||||
@@ -650,6 +653,10 @@ fn cell_look(wb: &Workbook, si: usize, sh: &Sheet, c: CellRef, cf: &mut Option<c
|
||||
if st.font.underline != Underline::None {
|
||||
style.font.underline = st.font.underline;
|
||||
}
|
||||
// In a differential style General means "not set" (#1163).
|
||||
if st.num_fmt.as_str() != "General" {
|
||||
style.num_fmt = st.num_fmt.clone();
|
||||
}
|
||||
for (dst, src) in [
|
||||
(&mut style.borders.left, st.borders.left),
|
||||
(&mut style.borders.right, st.borders.right),
|
||||
@@ -996,6 +1003,62 @@ fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Option<crate::
|
||||
pg.restore();
|
||||
}
|
||||
|
||||
struct PendingPdfLink {
|
||||
page: usize,
|
||||
sheet: usize,
|
||||
rect: (f32, f32, f32, f32),
|
||||
target: String,
|
||||
}
|
||||
|
||||
struct PrintedBlock {
|
||||
page: usize,
|
||||
sheet: usize,
|
||||
rows: (u32, u32),
|
||||
cols: (u32, u32),
|
||||
x: f32,
|
||||
y: f32,
|
||||
k: f32,
|
||||
}
|
||||
|
||||
/// PDF annotations are not affected by graphics clipping, so intersect each cell with its page
|
||||
/// block before adding a clickable rectangle. A repeated print-title cell may appear on several
|
||||
/// pages and receives one annotation on each of them.
|
||||
fn draw_block_links(pg: &mut Page, b: &Block, page: usize, pending: &mut Vec<PendingPdfLink>) {
|
||||
let range = b.range();
|
||||
let (Some(&bx), Some(&by), Some(&right), Some(&bottom)) = (b.xs.first(), b.ys.first(), b.xs.last(), b.ys.last()) else { return };
|
||||
for (cell, link) in b.sh.hyperlinks.range(range.start..=range.end) {
|
||||
if !range.contains(*cell) {
|
||||
continue;
|
||||
}
|
||||
let cell_range = b.sh.merges.iter().find(|m| m.start == *cell).copied().unwrap_or(RangeRef::cell(*cell));
|
||||
let (x, y, w, h) = b.rect(cell_range);
|
||||
let (x0, y0, x1, y1) = (x.max(bx), y.max(by), (x + w).min(right), (y + h).min(bottom));
|
||||
if x1 <= x0 || y1 <= y0 {
|
||||
continue;
|
||||
}
|
||||
let rect = (x0, y0, x1 - x0, y1 - y0);
|
||||
match crate::links::classify(&link.target) {
|
||||
crate::links::LinkKind::External(uri) => pg.link_uri(rect.0, rect.1, rect.2, rect.3, &uri),
|
||||
crate::links::LinkKind::Internal(target) if !link.external => {
|
||||
pending.push(PendingPdfLink { page, sheet: b.si, rect, target });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn printed_target(wb: &Workbook, blocks: &[PrintedBlock], source_sheet: usize, target: &str) -> Option<(usize, f32, f32)> {
|
||||
let target = target.trim().trim_start_matches('#').trim_start_matches('=');
|
||||
let (sheet_name, range) = split_sheet(target);
|
||||
let sheet = sheet_name.map_or(Some(source_sheet), |name| wb.sheet_index(&name))?;
|
||||
let cell = RangeRef::parse(&range.replace('$', ""))?.start;
|
||||
let sh = wb.sheet(sheet)?;
|
||||
let block = blocks.iter().find(|b| b.sheet == sheet && (b.rows.0..=b.rows.1).contains(&cell.row) && (b.cols.0..=b.cols.1).contains(&cell.col))?;
|
||||
let x = block.x + (sh.col_left(cell.col) - sh.col_left(block.cols.0)) as f32 * block.k;
|
||||
let y = block.y + (sh.row_top(cell.row) - sh.row_top(block.rows.0)) as f32 * block.k;
|
||||
Some((block.page, x, y))
|
||||
}
|
||||
|
||||
/// Cell pictures fit the current cell (or merged-cell) rectangle without changing aspect ratio.
|
||||
fn draw_cell_picture(pg: &mut Page, data: &[u8], rect: (f32, f32, f32, f32), k: f32) {
|
||||
let (x, y, w, h) = rect;
|
||||
@@ -1061,7 +1124,7 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3
|
||||
let avail = (w - 2.0 * pad - indent).max(0.0);
|
||||
let code = st.num_fmt.as_str();
|
||||
let formatted = crate::display::format(&v, code, wb);
|
||||
let mut color = formatted.color.map(numfmt_rgb).or_else(|| rgb_of(st.font.color, wb)).unwrap_or([0, 0, 0]);
|
||||
let mut color = formatted.color.and_then(gridcraft_numfmt::FormatColor::rgb).or_else(|| rgb_of(st.font.color, wb)).unwrap_or([0, 0, 0]);
|
||||
if l.cf.as_ref().and_then(|x| x.style.as_ref()).is_some_and(|s| s.font.color != Color::Auto) {
|
||||
color = rgb_of(st.font.color, wb).unwrap_or(color);
|
||||
}
|
||||
@@ -1076,7 +1139,8 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3
|
||||
let max_chars = (avail / digit).floor().max(1.0) as usize;
|
||||
gridcraft_numfmt::format_general_fit(n, max_chars.min(11)).unwrap_or_else(|| "#".repeat(max_chars.min(255)))
|
||||
} else {
|
||||
crate::display::cell_text(wb, sh, c)
|
||||
// The look's code, which a conditional format can override (#1163).
|
||||
crate::display::value_text_as(wb, &v, code)
|
||||
};
|
||||
if !wrap {
|
||||
text = text.replace(['\n', '\r'], " ");
|
||||
@@ -1462,6 +1526,8 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
|
||||
let total: usize = layouts.iter().map(|l| l.pages.len()).sum();
|
||||
let (date, time) = hf_date_time(wb);
|
||||
let mut page_no = 0usize;
|
||||
let mut pending_links = Vec::new();
|
||||
let mut printed_blocks = Vec::new();
|
||||
for lay in layouts {
|
||||
let Some(sh) = wb.sheet(lay.sheet) else { continue };
|
||||
let mut cf = if sh.cond_formats.is_empty() { None } else { Some(crate::cf::CfCache::new()) };
|
||||
@@ -1476,6 +1542,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
|
||||
let content_h = (lay.heading_h + rsegs.iter().map(|s| seg_size(sh, *s, true)).sum::<f32>()) * k;
|
||||
let ox = lay.margins[0] + if lay.settings.center_h { ((aw - content_w) / 2.0).max(0.0) } else { 0.0 };
|
||||
let oy = lay.margins[2] + if lay.settings.center_v { ((ah - content_h) / 2.0).max(0.0) } else { 0.0 };
|
||||
let page_index = doc.page_count();
|
||||
let pg = doc.add_page(lay.page_w, lay.page_h);
|
||||
let mut y = oy + lay.heading_h * k;
|
||||
let mut body: Option<Block> = None;
|
||||
@@ -1484,6 +1551,8 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
|
||||
for (csi, cs) in csegs.iter().enumerate() {
|
||||
let b = make_block(wb, lay.sheet, sh, *rs, *cs, x, y, lay);
|
||||
draw_block(pg, &b, lay.settings.gridlines, &mut cf);
|
||||
draw_block_links(pg, &b, page_index, &mut pending_links);
|
||||
printed_blocks.push(PrintedBlock { page: page_index, sheet: lay.sheet, rows: *rs, cols: *cs, x, y, k });
|
||||
if lay.settings.headings {
|
||||
draw_headings(pg, &b, rsi == 0, csi == 0, (ox, oy), (lay.heading_w * k, lay.heading_h * k));
|
||||
}
|
||||
@@ -1514,6 +1583,14 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
|
||||
draw_header_footer(pg, lay, lay.settings.hf_page(pi == 0, page_no), &cx);
|
||||
}
|
||||
}
|
||||
for link in pending_links {
|
||||
if let Some((target_page, x, y)) = printed_target(wb, &printed_blocks, link.sheet, &link.target)
|
||||
&& let Some(page) = doc.page_mut(link.page)
|
||||
{
|
||||
let (lx, ly, w, h) = link.rect;
|
||||
page.link_page(lx, ly, w, h, target_page, x, y);
|
||||
}
|
||||
}
|
||||
let n = doc.page_count();
|
||||
(doc.finish(), n)
|
||||
}
|
||||
@@ -1759,6 +1836,29 @@ mod tests {
|
||||
hay.windows(needle.len()).any(|w| w == needle.as_bytes())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_export_keeps_safe_links_on_their_printed_pages() {
|
||||
let mut s = s();
|
||||
s.execute("insert.link", json!({"cell": "A1", "target": "https://example.org/report"})).unwrap();
|
||||
s.execute("insert.link", json!({"cell": "A2", "target": "Sheet1!A60"})).unwrap();
|
||||
s.execute("insert.link", json!({"cell": "A3", "target": "file:///private/report"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A60", "input": "Destination"})).unwrap();
|
||||
let (pdf, pages) = pdf_of(&mut s, json!({}));
|
||||
assert_eq!(pages, 2);
|
||||
let text = String::from_utf8_lossy(&pdf);
|
||||
assert_eq!(text.matches("/Subtype /Link").count(), 2);
|
||||
assert!(text.contains("/URI (https://example.org/report)"));
|
||||
assert!(text.contains("/Dest [") && text.contains("/XYZ "));
|
||||
assert!(!text.contains("/URI (file:"));
|
||||
|
||||
// A link to a cell omitted by the selected print range must not point to a nonexistent
|
||||
// destination. The external link in the selected range remains clickable.
|
||||
let (cropped, _) = pdf_of(&mut s, json!({"range": "A1:A3"}));
|
||||
let cropped = String::from_utf8_lossy(&cropped);
|
||||
assert_eq!(cropped.matches("/Subtype /Link").count(), 1);
|
||||
assert!(!cropped.contains("/Dest ["));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_sections_split_join_and_field_names() {
|
||||
let spec = "&L&\"Arial,Bold\"Sales &&Co&CPage &P of &N&R&D";
|
||||
@@ -1894,6 +1994,16 @@ mod tests {
|
||||
assert!(contains(&pdf, "(Odd 1) Tj") && contains(&pdf, "(Odd 2) Tj") && !contains(&pdf, "(Cover) Tj"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_prints_cjk_text_instead_of_question_marks() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["签字清单", 2]]})).unwrap();
|
||||
let (pdf, _) = pdf_of(&mut s, json!({}));
|
||||
assert!(!contains(&pdf, "(????) Tj"), "CJK cell printed as (????) Tj");
|
||||
assert!(contains(&pdf, "<7B7E5B576E055355> Tj"));
|
||||
assert!(contains(&pdf, "(2) Tj"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_contains_text_and_header_codes_expand() {
|
||||
let mut s = s();
|
||||
@@ -1932,6 +2042,31 @@ mod tests {
|
||||
assert_eq!(expand_header_footer("&", &cx)[1].text, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditional_number_format_prints() {
|
||||
// #1163: a rule's number format replaces the cell's own, whether that is General or not;
|
||||
// cells the rule doesn't match keep theirs.
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [3], [4]]})).unwrap();
|
||||
s.execute("home.numberFormat", json!({"range": "A3", "code": "0.0"})).unwrap();
|
||||
let rule = json!({"type": "cellIs", "operator": "greater", "value": "2", "style": {"num_fmt": "0.00"}});
|
||||
s.execute("home.conditionalFormat", json!({"range": "A1:A3", "rule": rule})).unwrap();
|
||||
let (pdf, _) = pdf_of(&mut s, json!({}));
|
||||
assert!(contains(&pdf, "(1) Tj"), "an unmatched cell keeps General");
|
||||
assert!(contains(&pdf, "(3.00) Tj") && !contains(&pdf, "(3) Tj"), "General cell under the rule");
|
||||
assert!(contains(&pdf, "(4.00) Tj") && !contains(&pdf, "(4.0) Tj"), "formatted cell under the rule");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_uses_indexed_number_format_palette_color() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "42"})).unwrap();
|
||||
s.execute("home.numberFormat", json!({"range": "A1", "format": "[Color4]0"})).unwrap();
|
||||
let (pdf, pages) = pdf_of(&mut s, json!({}));
|
||||
assert_eq!(pages, 1);
|
||||
assert!(contains(&pdf, "0 1 0 rg"), "expected indexed Color4 green in PDF text stream");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_writes_file() {
|
||||
let mut s = s();
|
||||
@@ -2000,7 +2135,7 @@ mod tests {
|
||||
wb.sheet_mut(0).unwrap().images.push(Image {
|
||||
id,
|
||||
anchor: Anchor { cell: CellRef::new(5, 5), dx: 0.0, dy: 0.0, width: 40.0, height: 40.0, mode: gridcraft_model::AnchorMode::MoveAndSize },
|
||||
data: png,
|
||||
data: png.into(),
|
||||
mime: "image/png".into(),
|
||||
alt: String::new(),
|
||||
});
|
||||
@@ -2041,6 +2176,81 @@ mod tests {
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().print.print_area.is_some());
|
||||
}
|
||||
|
||||
/// The Japanese paper sizes are the JIS ones: `B4`/`B5`/`B6` changed meaning from ISO, and the
|
||||
/// ISO B series stays reachable by name. The postcards and envelopes are the JIS sizes too.
|
||||
#[test]
|
||||
fn japanese_paper_sizes_are_jis() {
|
||||
for (name, w, h) in [
|
||||
("A4", 595.28, 841.89), // unchanged
|
||||
("B4", 728.50, 1031.81),
|
||||
("B5", 515.91, 728.50),
|
||||
("B6", 362.83, 515.91),
|
||||
("B4 (JIS)", 728.50, 1031.81),
|
||||
("b5-jis", 515.91, 728.50),
|
||||
("B4 (ISO)", 708.66, 1000.63),
|
||||
("B5 (ISO)", 498.90, 708.66),
|
||||
("B6 (ISO)", 354.33, 498.90),
|
||||
("A6", 297.64, 419.53),
|
||||
("Japanese Postcard", 283.46, 419.53),
|
||||
("japanese double postcard", 419.53, 566.93),
|
||||
("Japanese Envelope #3", 340.16, 666.14),
|
||||
("chou #4", 255.12, 581.10),
|
||||
("Japanese Envelope Kaku #2", 680.31, 941.10),
|
||||
("kaku #3", 612.28, 785.20),
|
||||
] {
|
||||
let got = paper_size(name);
|
||||
assert!((got.0 - w).abs() < 0.01 && (got.1 - h).abs() < 0.01, "{name}: {got:?}");
|
||||
}
|
||||
// 長形4号 is 90×205 mm (90×225 mm is 長形40号), so its height is 205 mm = 581.10 pt — not the
|
||||
// 225 mm it once had.
|
||||
let chou4 = paper_size("Japanese Envelope #4");
|
||||
assert_eq!((chou4.0, chou4.1), (255.12, 581.10));
|
||||
assert_eq!(chou4, paper_size("chou #4"));
|
||||
// Unknown names keep the old Letter fallback.
|
||||
assert_eq!(paper_size("Napkin"), (612.0, 792.0));
|
||||
}
|
||||
|
||||
/// Page Setup's paper reaches the PDF page: the MediaBox is the paper's, A4 is unchanged, B5 is
|
||||
/// JIS B5, and the Japanese postcard prints at 283.46×419.53 pt. Landscape swaps the sides and
|
||||
/// fit-to-page keeps the paper size.
|
||||
#[test]
|
||||
fn page_setup_paper_sizes_reach_the_pdf_page() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["x"]]})).unwrap();
|
||||
let close = |v: &Json, want: f64| (v.as_f64().unwrap() - want).abs() < 0.01;
|
||||
for (paper, w, h) in [
|
||||
("A4", 595.28f64, 841.89f64),
|
||||
("B5", 515.91, 728.50),
|
||||
("Japanese Postcard", 283.46, 419.53),
|
||||
("Japanese Double Postcard", 419.53, 566.93),
|
||||
("Japanese Envelope Kaku #2", 680.31, 941.10),
|
||||
] {
|
||||
s.execute("pageLayout.pageSetup", json!({"paper": paper, "orientation": "portrait"})).unwrap();
|
||||
let pv = s.execute("file.printPreview", json!({})).unwrap();
|
||||
let page = &pv["pageList"][0];
|
||||
assert_eq!(page["paper"], paper);
|
||||
assert!(close(&page["width"], w) && close(&page["height"], h), "{paper}: {page}");
|
||||
let (pdf, pages) = pdf_of(&mut s, json!({}));
|
||||
assert_eq!(pages, 1);
|
||||
let want = format!("/MediaBox [0 0 {w} {h}]");
|
||||
assert!(contains(&pdf, &want), "{paper}: wanted {want}");
|
||||
}
|
||||
// Landscape swaps the sides, and fit-to-page does not change the paper.
|
||||
s.execute("pageLayout.pageSetup", json!({"paper": "B5", "orientation": "landscape"})).unwrap();
|
||||
let (pdf, pages) = pdf_of(&mut s, json!({}));
|
||||
assert_eq!(pages, 1);
|
||||
assert!(contains(&pdf, "/MediaBox [0 0 728.5 515.91]"));
|
||||
s.execute("pageLayout.pageSetup", json!({"paper": "B5", "orientation": "portrait"})).unwrap();
|
||||
let (pdf, _) = pdf_of(&mut s, json!({"fitToPage": true}));
|
||||
assert!(contains(&pdf, "/MediaBox [0 0 515.91 728.5]"));
|
||||
// `pageLayout.size` still takes any name, and an unknown one prints on Letter as before.
|
||||
s.execute("pageLayout.size", json!({"paper": "Napkin"})).unwrap();
|
||||
let pv = s.execute("file.printPreview", json!({})).unwrap();
|
||||
assert!(close(&pv["pageList"][0]["width"], 612.0) && close(&pv["pageList"][0]["height"], 792.0));
|
||||
// Page Setup refuses an unknown name.
|
||||
assert!(s.execute("pageLayout.pageSetup", json!({"paper": "Napkin"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_settings_never_panic_and_cap_pages() {
|
||||
let mut s = s();
|
||||
|
||||
@@ -139,11 +139,15 @@ fn protect_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
return Err(EngineError::Other("The sheet is protected with a password. Unprotect it first (Review › Unprotect Sheet).".into()));
|
||||
}
|
||||
let b = |k: &str| bool_param(p, k).unwrap_or(false);
|
||||
// Selection rights default to what the sheet has now (a file may forbid selecting locked cells).
|
||||
let current = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.protection.clone());
|
||||
let select_locked = bool_param(p, "selectLockedCells").unwrap_or_else(|| current.as_ref().is_none_or(|c| c.select_locked));
|
||||
let select_unlocked = bool_param(p, "selectUnlockedCells").unwrap_or_else(|| current.as_ref().is_none_or(|c| c.select_unlocked));
|
||||
let prot = SheetProtection {
|
||||
password: None,
|
||||
legacy_password_hash: None,
|
||||
select_locked: true,
|
||||
select_unlocked: true,
|
||||
select_locked,
|
||||
select_unlocked,
|
||||
format_cells: b("formatCells"),
|
||||
format_columns: b("formatColumns"),
|
||||
format_rows: b("formatRows"),
|
||||
@@ -399,6 +403,26 @@ mod tests {
|
||||
assert_eq!(at(&s), (0, "A1".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protection_that_forbids_selecting_locked_cells_is_enforced_and_kept() {
|
||||
let mut s = book();
|
||||
s.execute("home.lockCell", json!({"range": "B2", "on": false})).unwrap();
|
||||
s.execute("review.protectSheet", json!({"selectLockedCells": false})).unwrap();
|
||||
// Locked cells can't be selected; unlocked ones can.
|
||||
assert!(s.execute("selection.set", json!({"cell": "A1"})).is_err());
|
||||
s.execute("selection.set", json!({"cell": "B2"})).unwrap();
|
||||
// Arrow keys skip locked cells, and stay put when nothing selectable is left.
|
||||
s.execute("selection.move", json!({"dr": 0, "dc": 1})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.active.a1(), "B2");
|
||||
// Re-protecting without the flag keeps the sheet's selection rights.
|
||||
s.execute("review.unprotectSheet", json!({})).unwrap();
|
||||
s.execute("review.protectSheet", json!({"selectLockedCells": false})).unwrap();
|
||||
s.execute("review.protectSheet", json!({"sort": true})).unwrap();
|
||||
assert!(!s.doc().unwrap().wb.active().unwrap().protection.clone().unwrap().select_locked);
|
||||
s.execute("review.unprotectSheet", json!({})).unwrap();
|
||||
s.execute("selection.set", json!({"cell": "A1"})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sheet_passwords_use_excel_hashes_and_old_json_hashes_still_verify() {
|
||||
let mut s = book();
|
||||
@@ -426,6 +450,32 @@ mod tests {
|
||||
s.execute("review.unprotectSheet", json!({"password": "old"})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protected_structure_blocks_rename_move_copy_hide_and_unhide_sheet() {
|
||||
let mut s = book();
|
||||
let names = |s: &Session| s.doc().unwrap().wb.sheets.iter().map(|x| x.name.clone()).collect::<Vec<_>>();
|
||||
let before = names(&s);
|
||||
s.execute("review.protectWorkbook", json!({})).unwrap();
|
||||
assert!(s.execute("sheet.rename", json!({"sheet": 0, "name": "Renamed"})).is_err());
|
||||
assert!(s.execute("sheet.move", json!({"sheet": 0, "to": 1})).is_err());
|
||||
assert!(s.execute("sheet.move", json!({"sheet": 0, "to": 1, "copy": true})).is_err());
|
||||
assert!(s.execute("sheet.hide", json!({"sheet": 1})).is_err());
|
||||
assert_eq!(names(&s), before);
|
||||
assert!(s.doc().unwrap().wb.sheets.iter().all(|x| x.visibility == Visibility::Visible));
|
||||
s.execute("review.protectWorkbook", json!({})).unwrap();
|
||||
s.execute("sheet.rename", json!({"sheet": 0, "name": "Renamed"})).unwrap();
|
||||
s.execute("sheet.move", json!({"sheet": 0, "to": 1})).unwrap();
|
||||
s.execute("sheet.move", json!({"sheet": 0, "to": 1, "copy": true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.sheets.len(), 3);
|
||||
s.execute("sheet.hide", json!({"sheet": 1})).unwrap();
|
||||
s.execute("review.protectWorkbook", json!({})).unwrap();
|
||||
assert!(s.execute("sheet.unhide", json!({"sheet": 1})).is_err());
|
||||
assert!(s.execute("sheet.unhide", json!({})).is_err());
|
||||
assert_eq!(s.doc().unwrap().wb.sheets[1].visibility, Visibility::Hidden);
|
||||
s.execute("review.protectWorkbook", json!({})).unwrap();
|
||||
s.execute("sheet.unhide", json!({"sheet": 1})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protect_workbook_with_a_password_really_verifies() {
|
||||
let mut s = book();
|
||||
|
||||
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
fn lines(s: &Session, p: &Json, axis: Axis) -> Result<(u32, u32)> {
|
||||
let key = if axis == Axis::Rows { "rows" } else { "cols" };
|
||||
let r = match str_param(p, key).or_else(|| str_param(p, "range")) {
|
||||
Some(t) => RangeRef::parse(t).ok_or_else(|| bad(key, format!("not a range: {t}")))?,
|
||||
Some(t) => parse_lines(t).ok_or_else(|| bad(key, format!("not a range: {t}")))?,
|
||||
None => s.doc()?.selection.current(),
|
||||
};
|
||||
let (a, b) = if axis == Axis::Rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) };
|
||||
@@ -211,10 +211,19 @@ fn shift_sheet_features(sh: &mut Sheet, axis: Axis, at: u32, count: u32, insert:
|
||||
if let Some(pa) = sh.print.print_area {
|
||||
sh.print.print_area = map_range(pa);
|
||||
}
|
||||
// PivotTables follow their report cells. A deleted anchor line collapses onto the line that
|
||||
// takes its place; a report deleted entirely has no last area until the next refresh.
|
||||
for pt in sh.pivots.iter_mut() {
|
||||
pt.anchor = map_cell(pt.anchor).unwrap_or_else(|| match axis {
|
||||
Axis::Rows => CellRef::new(at, pt.anchor.col),
|
||||
Axis::Cols => CellRef::new(pt.anchor.row, at),
|
||||
});
|
||||
pt.last_range = pt.last_range.and_then(map_range);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrites every formula reference (and chart series) for a structural edit on `target`.
|
||||
fn rewrite(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit) {
|
||||
pub(super) fn rewrite(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit) {
|
||||
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();
|
||||
@@ -226,6 +235,8 @@ fn rewrite(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit) {
|
||||
}
|
||||
};
|
||||
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);
|
||||
@@ -235,6 +246,18 @@ fn rewrite(wb: &mut gridcraft_model::Workbook, target: &str, e: &Edit) {
|
||||
for sp in sh.sparklines.iter_mut() {
|
||||
sp.source = fix(&sp.source);
|
||||
}
|
||||
// PivotTable sources (a range, table or defined name) are only rewritten when the edit
|
||||
// moves them, so table and name sources keep their text.
|
||||
for pt in sh.pivots.iter_mut() {
|
||||
let body = pt.source.trim().trim_start_matches('=');
|
||||
if let Ok(expr) = gridcraft_formula::parse(body) {
|
||||
let before = gridcraft_formula::print(&expr);
|
||||
let after = gridcraft_formula::print(&gridcraft_formula::adjust::adjust(expr, &host, target, e));
|
||||
if after != before {
|
||||
pt.source = after;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,7 +329,7 @@ fn delete_areas(s: &Session, p: &Json, axis: Axis) -> Result<Vec<(u32, u32)>> {
|
||||
} else if let Some(t) = str_param(p, key).or_else(|| str_param(p, "range")) {
|
||||
t.split(',')
|
||||
.map(|part| {
|
||||
let r = RangeRef::parse(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?;
|
||||
let r = parse_lines(part).ok_or_else(|| bad(key, format!("not a range: {part}")))?;
|
||||
Ok(if axis == Axis::Rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) })
|
||||
})
|
||||
.collect::<Result<_>>()?
|
||||
@@ -469,6 +492,15 @@ fn insert_cells(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
if let Some(sh) = s.doc()?.wb.sheet(sheet) {
|
||||
check_table_shift(sh, &e)?;
|
||||
// Refuse to push data off the sheet: cells in the last `width`/`height` positions would be dropped.
|
||||
let doomed = if shift == "right" {
|
||||
RangeRef::new(CellRef::new(r.start.row, r.start.col.max(MAX_COLS.saturating_sub(r.width()))), CellRef::new(r.end.row, MAX_COLS - 1))
|
||||
} 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()));
|
||||
}
|
||||
}
|
||||
let right = shift == "right";
|
||||
let out = edit(s, |cx| {
|
||||
@@ -746,7 +778,7 @@ fn delete_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
*sc -= 1;
|
||||
}
|
||||
// Names referring to the sheet become #REF!.
|
||||
if let Ok(e) = gridcraft_formula::parse(&n.formula) {
|
||||
if let Ok(e) = gridcraft_formula::parse_name(&n.formula) {
|
||||
let ne = gridcraft_formula::adjust::delete_sheet(e.clone(), &name, &order);
|
||||
if ne != e {
|
||||
n.formula = gridcraft_formula::print(&ne);
|
||||
@@ -767,6 +799,9 @@ fn delete_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
fn rename_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if s.doc()?.wb.protected_structure {
|
||||
return Err(EngineError::Other("The workbook is protected and cannot be changed.".into()));
|
||||
}
|
||||
let i = target_sheet(s, p)?;
|
||||
let Some(name) = str_param(p, "name").map(|n| n.trim().to_string()) else { return Err(bad("sheet.rename", "missing `name`")) };
|
||||
s.doc()?.wb.check_sheet_name(&name, Some(i)).map_err(EngineError::Other)?;
|
||||
@@ -808,7 +843,7 @@ fn rename_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
for n in cx.wb.names.iter_mut() {
|
||||
if let Ok(e) = gridcraft_formula::parse(&n.formula) {
|
||||
if let Ok(e) = gridcraft_formula::parse_name(&n.formula) {
|
||||
n.formula = gridcraft_formula::print(&gridcraft_formula::adjust::rename_sheet(e, &old, &name));
|
||||
}
|
||||
}
|
||||
@@ -818,6 +853,9 @@ fn rename_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if s.doc()?.wb.protected_structure {
|
||||
return Err(EngineError::Other("The workbook is protected and cannot be changed.".into()));
|
||||
}
|
||||
let i = target_sheet(s, p)?;
|
||||
let copy = bool_param(p, "copy").unwrap_or(false);
|
||||
let to = p.get("to").and_then(Json::as_u64).map(|v| v as usize).ok_or_else(|| bad("sheet.move", "missing `to`"))?;
|
||||
@@ -847,7 +885,7 @@ fn move_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
.iter()
|
||||
.filter(|nm| nm.scope == Some(i))
|
||||
.map(|nm| {
|
||||
let formula = gridcraft_formula::parse(&nm.formula)
|
||||
let formula = gridcraft_formula::parse_name(&nm.formula)
|
||||
.map(|e| gridcraft_formula::print(&gridcraft_formula::adjust::rename_sheet(e, &base, &sh.name)))
|
||||
.unwrap_or_else(|_| nm.formula.clone());
|
||||
gridcraft_model::DefinedName { scope: Some(at), formula, ..nm.clone() }
|
||||
@@ -895,6 +933,9 @@ fn tab_color(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
fn hide_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if s.doc()?.wb.protected_structure {
|
||||
return Err(EngineError::Other("The workbook is protected and cannot be changed.".into()));
|
||||
}
|
||||
let i = target_sheet(s, p)?;
|
||||
if s.doc()?.wb.sheets.iter().filter(|s| s.visibility == Visibility::Visible).count() <= 1 {
|
||||
return Err(EngineError::Other("A workbook must contain at least one visible worksheet.".into()));
|
||||
@@ -909,6 +950,9 @@ fn hide_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
fn unhide_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if s.doc()?.wb.protected_structure {
|
||||
return Err(EngineError::Other("The workbook is protected and cannot be changed.".into()));
|
||||
}
|
||||
if p.get("sheet").is_none() {
|
||||
let hidden: Vec<String> = s.doc()?.wb.sheets.iter().filter(|s| s.visibility != Visibility::Visible).map(|s| s.name.clone()).collect();
|
||||
if hidden.is_empty() {
|
||||
@@ -932,17 +976,18 @@ fn activate(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Ok(json!({"sheet": i, "name": d.wb.active().map(|s| s.name.clone())}))
|
||||
}
|
||||
|
||||
fn step_sheet(s: &mut Session, dir: i64) -> Result<Json> {
|
||||
fn step_sheet(s: &mut Session, dir: isize) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let n = d.wb.sheets.len() as i64;
|
||||
let mut i = d.wb.active_sheet as i64;
|
||||
for _ in 0..n {
|
||||
i = (i + dir).clamp(0, n - 1);
|
||||
if d.wb.sheet(i as usize).is_some_and(|s| s.visibility == Visibility::Visible) {
|
||||
break;
|
||||
let mut i = d.wb.active_sheet;
|
||||
while let Some(next) = i.checked_add_signed(dir) {
|
||||
let Some(sheet) = d.wb.sheet(next) else { break };
|
||||
if sheet.visibility == Visibility::Visible {
|
||||
s.doc_mut()?.wb_switch_sheet(next);
|
||||
return ok();
|
||||
}
|
||||
i = next;
|
||||
}
|
||||
s.doc_mut()?.wb_switch_sheet(i as usize);
|
||||
// No visible sheet in this direction: keep the current sheet and its selection.
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -963,7 +1008,7 @@ pub(crate) fn map_range(r: RangeRef, e: &Edit) -> Option<RangeRef> {
|
||||
|
||||
/// Shifts what Insert/Delete Cells moves along with the cells: merged areas, conditional
|
||||
/// format, validation, table, filter and print-area ranges, notes, links and sparklines.
|
||||
fn shift_cell_features(sh: &mut Sheet, e: &Edit) {
|
||||
pub(super) fn shift_cell_features(sh: &mut Sheet, e: &Edit) {
|
||||
let map_cell = |c: CellRef| map_range(RangeRef::cell(c), e).map(|r| r.start);
|
||||
sh.merges = sh.merges.iter().filter_map(|m| map_range(*m, e)).filter(|m| !m.is_single()).collect();
|
||||
for cf in sh.cond_formats.iter_mut() {
|
||||
|
||||
@@ -97,11 +97,21 @@ fn reapply_slicer(wb: &mut Workbook, sheet: usize, id: u32) {
|
||||
}
|
||||
|
||||
fn insert_slicer(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
// A table named by `table` is found on any sheet, as the other table commands find it, and the slicer goes on that
|
||||
// table's sheet. Looked up on the active sheet only, a named table on another sheet gave "Select a cell inside a
|
||||
// table first, or pass `table`" although `table` was passed.
|
||||
let named = match str_param(p, "table") {
|
||||
Some(name) => Some(s.doc()?.wb.table(name).ok_or_else(|| bad("insert.slicer", format!("no table named `{name}`")))?),
|
||||
None => None,
|
||||
};
|
||||
let sheet = match named {
|
||||
Some((si, _)) => si,
|
||||
None => target_sheet(s, p)?,
|
||||
};
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.sheet(sheet).ok_or(EngineError::NoDocument)?;
|
||||
let t = match str_param(p, "table") {
|
||||
Some(name) => sh.tables.iter().find(|t| t.name.eq_ignore_ascii_case(name)),
|
||||
let t = match named {
|
||||
Some((_, ti)) => sh.tables.get(ti),
|
||||
None => sh.table_at(d.selection.active),
|
||||
}
|
||||
.ok_or_else(|| EngineError::Other("Select a cell inside a table first, or pass `table`.".into()))?;
|
||||
@@ -119,7 +129,7 @@ fn insert_slicer(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
};
|
||||
let table = t.name.clone();
|
||||
let at = cell_param(s, p, "at")?
|
||||
let at = cell_param_on(s, p, "at", sheet)?
|
||||
.unwrap_or_else(|| CellRef::new(t.range.start.row, t.range.end.col.saturating_add(2).min(gridcraft_core::MAX_COLS - 1)));
|
||||
let columns = u32_param(p, "columns").unwrap_or(1).clamp(1, 10);
|
||||
let width = f64_param(p, "width").unwrap_or(168.0).clamp(60.0, 10000.0) as f32;
|
||||
|
||||
+201
-56
@@ -1863,13 +1863,11 @@ fn limits_report(d: &DocState, sh: &Sheet, inp: &ReportInput) -> Result<Lines> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Writes the reports on new sheets ("Answer Report 1"…) before the model's sheet, in one step;
|
||||
/// the model's sheet stays active. Returns the new sheets' names.
|
||||
fn write_reports(s: &mut Session, kinds: &[ReportKind], inp: &ReportInput) -> Result<Vec<String>> {
|
||||
/// Builds every report before the solution or saved model can change.
|
||||
fn prepare_reports(d: &DocState, kinds: &[ReportKind], inp: &ReportInput) -> Result<Vec<(String, Lines)>> {
|
||||
if kinds.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let d = s.doc()?;
|
||||
if d.wb.protected_structure {
|
||||
return Err(EngineError::Other("The workbook is protected and cannot be changed.".into()));
|
||||
}
|
||||
@@ -1886,41 +1884,48 @@ fn write_reports(s: &mut Session, kinds: &[ReportKind], inp: &ReportInput) -> Re
|
||||
.ok_or_else(|| EngineError::Other("Too many report sheets.".into()))?;
|
||||
built.push((name, lines));
|
||||
}
|
||||
Ok(built)
|
||||
}
|
||||
|
||||
/// Inserts prepared reports before the model's sheet, which stays active.
|
||||
fn write_reports(cx: &mut Ctx, at: usize, built: Vec<(String, Lines)>) -> Vec<String> {
|
||||
let names: Vec<String> = built.iter().map(|(n, _)| n.clone()).collect();
|
||||
let at = inp.model.sheet;
|
||||
let count = built.len();
|
||||
edit(s, |cx| {
|
||||
let bold = cx.wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| st.font.bold = true);
|
||||
for (i, (name, lines)) in built.into_iter().enumerate() {
|
||||
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 });
|
||||
}
|
||||
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)] {
|
||||
sheet.cols.insert(c, gridcraft_model::LineInfo { size: Some(w), ..Default::default() });
|
||||
}
|
||||
cx.wb.sheets.insert(at + i, std::sync::Arc::new(sheet));
|
||||
if count == 0 {
|
||||
return names;
|
||||
}
|
||||
let bold = cx.wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| st.font.bold = true);
|
||||
for (i, (name, lines)) in built.into_iter().enumerate() {
|
||||
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 });
|
||||
}
|
||||
for nm in cx.wb.names.iter_mut() {
|
||||
if let Some(sc) = nm.scope.as_mut()
|
||||
&& *sc >= at
|
||||
{
|
||||
*sc += count;
|
||||
}
|
||||
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)] {
|
||||
sheet.cols.insert(c, gridcraft_model::LineInfo { size: Some(w), ..Default::default() });
|
||||
}
|
||||
if cx.wb.active_sheet >= at {
|
||||
cx.wb.active_sheet += count;
|
||||
cx.wb.sheets.insert(at + i, std::sync::Arc::new(sheet));
|
||||
}
|
||||
for nm in cx.wb.names.iter_mut() {
|
||||
if let Some(sc) = nm.scope.as_mut()
|
||||
&& *sc >= at
|
||||
{
|
||||
*sc += count;
|
||||
}
|
||||
cx.structural = true;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
if cx.wb.active_sheet >= at {
|
||||
cx.wb.active_sheet += count;
|
||||
}
|
||||
cx.structural = true;
|
||||
names
|
||||
}
|
||||
|
||||
fn insert_report_selections(s: &mut Session, at: usize, count: usize) {
|
||||
if let Ok(d) = s.doc_mut() {
|
||||
for i in 0..count {
|
||||
d.sheet_selections.insert((at + i).min(d.sheet_selections.len()), crate::Selection::default());
|
||||
}
|
||||
}
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// `data.solverReport`: reports for a solution of the sheet's saved model.
|
||||
@@ -1973,7 +1978,9 @@ fn report(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
.map_or(DEFAULT_EVALS, |v| (v as u64).min(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 sheets = write_reports(s, &kinds, &inp)?;
|
||||
let built = prepare_reports(s.doc()?, &kinds, &inp)?;
|
||||
let sheets = edit(s, |cx| Ok(write_reports(cx, model.sheet, built)))?;
|
||||
insert_report_selections(s, model.sheet, sheets.len());
|
||||
Ok(json!({"sheets": sheets}))
|
||||
}
|
||||
|
||||
@@ -2068,41 +2075,53 @@ 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();
|
||||
if changed || save {
|
||||
let sheet = model.sheet;
|
||||
let vars = model.vars.clone();
|
||||
let m = model.clone();
|
||||
edit(s, |cx| {
|
||||
if changed && let Some(x) = &solution {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
for (c, v) in vars.iter().zip(x) {
|
||||
let style = sh.style_id(*c);
|
||||
sh.set_cell(*c, Cell { value: Value::number(*v), formula: None, style });
|
||||
}
|
||||
for c in &vars {
|
||||
cx.touch(sheet, *c);
|
||||
}
|
||||
}
|
||||
if save {
|
||||
save_model(&mut cx.wb, &m);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
let built = 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)?
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let count = built.len();
|
||||
let before = s.doc()?.wb.clone();
|
||||
let mut out = json!({
|
||||
"status": status,
|
||||
"message": message,
|
||||
"objective": objective,
|
||||
"values": values,
|
||||
"original": original,
|
||||
"model": model_params(&s.doc()?.wb, &model),
|
||||
"model": model_params(&before, &model),
|
||||
"evaluations": evaluations,
|
||||
"changed": changed,
|
||||
});
|
||||
// Reports describe a solution; they are part of the solve's undo step.
|
||||
if let (Some(kinds), Some(x)) = (reports, solution) {
|
||||
let inp = ReportInput { model: &model, prob: &prob, original: x0, solution: x, message: message.to_string(), budget };
|
||||
out["reports"] = json!(write_reports(s, &kinds, &inp)?);
|
||||
let names = if changed || save || count > 0 {
|
||||
let sheet = model.sheet;
|
||||
edit(s, |cx| {
|
||||
if changed && let Some(x) = &solution {
|
||||
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 });
|
||||
}
|
||||
for c in &model.vars {
|
||||
cx.touch(sheet + count, *c);
|
||||
}
|
||||
}
|
||||
if save {
|
||||
save_model(&mut cx.wb, &model);
|
||||
}
|
||||
if count > 0 {
|
||||
// Inserting sheets makes this structural, so retain the variable edit's array check.
|
||||
check_arrays(&before, &cx.wb)?;
|
||||
}
|
||||
Ok(write_reports(cx, sheet, built))
|
||||
})?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
insert_report_selections(s, model.sheet, count);
|
||||
if reports.is_some() && solution.is_some() {
|
||||
out["reports"] = json!(names);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -2390,6 +2409,132 @@ mod tests {
|
||||
assert_eq!(at(&s, "Answer Report 1", &format!("E{r}")), Value::number(2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refused_solver_reports_preserve_the_document_and_history() {
|
||||
for (protected, method, constraints, reports, error) in [
|
||||
(true, "simplex", json!(["A1 <= 4"]), json!(["answer"]), "workbook is protected"),
|
||||
(false, "simplex", json!(["A1 <= 4", "A1 int"]), json!(["answer", "limits"]), "integer or binary"),
|
||||
(false, "evolutionary", json!(["A1 <= 4"]), json!(["answer", "limits"]), "Simplex LP and GRG"),
|
||||
] {
|
||||
for (keep, save) in [(true, true), (false, true), (true, false), (false, false)] {
|
||||
let mut s = s();
|
||||
set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]);
|
||||
s.execute(
|
||||
"data.solver",
|
||||
json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 2"], "method": "simplex", "keep": false}),
|
||||
)
|
||||
.unwrap();
|
||||
set(&mut s, &[("C1", "redo")]);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
let d = s.doc_mut().unwrap();
|
||||
std::sync::Arc::make_mut(&mut d.wb).protected_structure = protected;
|
||||
d.saved = d.wb.clone();
|
||||
let before = d.clone();
|
||||
let journal = s.journal.clone();
|
||||
let err = s
|
||||
.execute(
|
||||
"data.solver",
|
||||
json!({"objective": "B1", "variables": "A1", "constraints": constraints, "method": method,
|
||||
"reports": reports, "keep": keep, "saveModel": save}),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains(error), "{err}");
|
||||
let d = s.doc().unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&d.wb, &before.wb), "{method}: keep={keep}, save={save}: {err}");
|
||||
assert!(!d.is_dirty());
|
||||
assert_eq!(d.revision, before.revision);
|
||||
assert_eq!(d.selection, before.selection);
|
||||
assert_eq!(d.sheet_selections, before.sheet_selections);
|
||||
for (after, before) in [(&d.undo, &before.undo), (&d.redo, &before.redo)] {
|
||||
assert_eq!(after.len(), before.len());
|
||||
for (a, b) in after.iter().zip(before) {
|
||||
assert!(std::sync::Arc::ptr_eq(&a.wb, &b.wb));
|
||||
assert_eq!((&a.label, &a.selection), (&b.label, &b.selection));
|
||||
}
|
||||
}
|
||||
assert_eq!(s.journal, journal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solver_reports_and_optional_solution_and_model_share_one_undo() {
|
||||
for (keep, save) in [(true, true), (false, true), (true, false), (false, false)] {
|
||||
let mut s = s();
|
||||
set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]);
|
||||
let before = s.doc().unwrap().clone();
|
||||
let out = s
|
||||
.execute(
|
||||
"data.solver",
|
||||
json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 4"], "method": "simplex",
|
||||
"reports": ["answer"], "keep": keep, "saveModel": save}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(out["status"], "optimal");
|
||||
assert_eq!(out["reports"], json!(["Answer Report 1"]));
|
||||
assert_eq!(out["model"]["objective"], "Sheet1!B1");
|
||||
assert_eq!(n(&s, "A1"), if keep { 4.0 } else { 1.0 });
|
||||
assert_eq!(saved(&s), if save { "solver_opt" } else { "" });
|
||||
assert_eq!(s.doc().unwrap().undo.len(), before.undo.len() + 1);
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().name, "Sheet1");
|
||||
let r = row_of(&s, "Answer Report 1", "$A$1") + 1;
|
||||
assert_eq!(at(&s, "Answer Report 1", &format!("E{r}")), Value::number(4.0));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &before.wb));
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.sheets.len(), 2);
|
||||
assert_eq!(n(&s, "A1"), if keep { 4.0 } else { 1.0 });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solver_reports_keep_row_autofit_on_the_model_sheet() {
|
||||
let mut s = s();
|
||||
set(&mut s, &[("A1", "1"), ("B1", "=2*A1")]);
|
||||
s.execute("home.columnWidth", json!({"cols": "A:A", "width": 30})).unwrap();
|
||||
s.execute("home.wrapText", json!({"range": "A1", "on": true})).unwrap();
|
||||
let before = s.doc().unwrap().wb.active().unwrap().row_height(0);
|
||||
s.execute(
|
||||
"data.solver",
|
||||
json!({"objective": "B1", "variables": "A1", "constraints": ["A1 <= 1000000"], "method": "simplex", "reports": ["answer"]}),
|
||||
)
|
||||
.unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sh.name, "Sheet1");
|
||||
assert_eq!(sh.value(CellRef::new(0, 0)), Value::number(1_000_000.0));
|
||||
assert!(sh.row_height(0) > before, "the solved wrapped number must re-fit its row after the report shifts its sheet");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().row_height(0), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solver_report_insertion_keeps_variable_edit_guards() {
|
||||
for array in [false, true] {
|
||||
let mut s = s();
|
||||
set(&mut s, &[("A2", "1"), ("B1", "=2*A2")]);
|
||||
let error = if array {
|
||||
s.execute("selection.set", json!({"range": "A1:A2"})).unwrap();
|
||||
s.execute("cell.set", json!({"input": "=1", "array": true})).unwrap();
|
||||
"part of an array"
|
||||
} else {
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
"protected sheet"
|
||||
};
|
||||
let before = s.doc().unwrap().clone();
|
||||
let err = s
|
||||
.execute(
|
||||
"data.solver",
|
||||
json!({"objective": "B1", "variables": "A2", "constraints": ["A2 <= 4"], "method": "simplex", "reports": ["answer"]}),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains(error), "{err}");
|
||||
let d = s.doc().unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&d.wb, &before.wb));
|
||||
assert_eq!(d.undo.len(), before.undo.len());
|
||||
assert_eq!(d.sheet_selections, before.sheet_selections);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_report_of_a_nonlinear_model() {
|
||||
// The largest rectangle with perimeter at most 20 (5 × 5): with the height held at 5, the
|
||||
|
||||
@@ -13,19 +13,30 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Freeze Panes",
|
||||
["View", "Window", "Freeze Panes"],
|
||||
None,
|
||||
"{cell?: \"B2\" (rows above and columns left of it freeze)}",
|
||||
"{sheet?, cell?: \"B2\" (rows above and columns left of it freeze); without `cell`, unfreezes a frozen sheet}",
|
||||
has_doc,
|
||||
freeze_panes
|
||||
),
|
||||
cmd!("view.freezeTopRow", "Freeze Top Row", ["View", "Window", "Freeze Panes"], None, "{}", has_doc, |s, _| set_freeze(s, Some((1, 0)))),
|
||||
cmd!("view.freezeFirstColumn", "Freeze First Column", ["View", "Window", "Freeze Panes"], None, "{}", has_doc, |s, _| set_freeze(
|
||||
cmd!("view.freezeTopRow", "Freeze Top Row", ["View", "Window", "Freeze Panes"], None, "{sheet?}", has_doc, |s, p| set_freeze(
|
||||
s,
|
||||
target_sheet(s, p)?,
|
||||
Some((1, 0))
|
||||
)),
|
||||
cmd!("view.freezeFirstColumn", "Freeze First Column", ["View", "Window", "Freeze Panes"], None, "{sheet?}", has_doc, |s, p| set_freeze(
|
||||
s,
|
||||
target_sheet(s, p)?,
|
||||
Some((0, 1))
|
||||
)),
|
||||
cmd!("view.unfreezePanes", "Unfreeze Panes", ["View", "Window", "Freeze Panes"], None, "{}", has_doc, |s, _| set_freeze(s, None)),
|
||||
cmd!("view.unfreezePanes", "Unfreeze Panes", ["View", "Window", "Freeze Panes"], None, "{sheet?}", has_doc, |s, p| set_freeze(
|
||||
s,
|
||||
target_sheet(s, p)?,
|
||||
None
|
||||
)),
|
||||
cmd!("view.gridlines", "Gridlines", ["View", "Show"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "gridlines")),
|
||||
cmd!("view.headings", "Headings", ["View", "Show"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "headings")),
|
||||
cmd!("view.showZeros", "Show Zeros", [], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "zeros")),
|
||||
cmd!("view.showOutlineSymbols", "Show Outline Symbols", [], Some("Ctrl+8"), "{on?}", has_doc, |s, p| sheet_flag(s, p, "outlineSymbols")),
|
||||
cmd!("view.showObjects", "Show Objects", [], Some("Ctrl+6"), "{on?}", has_doc, show_objects),
|
||||
cmd!("view.rightToLeft", "Sheet Right-to-Left", ["Page Layout", "Sheet Options"], None, "{on?}", has_doc, |s, p| sheet_flag(s, p, "rtl")),
|
||||
cmd!("view.zoom", "Zoom", ["View", "Zoom"], None, "{percent: 10..400}", has_doc, zoom),
|
||||
cmd!("view.zoom100", "100%", ["View", "Zoom"], None, "{}", has_doc, |s, _| zoom(s, &json!({"percent": 100}))),
|
||||
@@ -35,7 +46,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
cmd!("view.zoomToSelection", "Zoom to Selection", ["View", "Zoom"], None, "{viewWidth?, viewHeight?}", has_doc, zoom_selection),
|
||||
cmd!("pageLayout.orientation", "Orientation", ["Page Layout", "Page Setup"], None, "{orientation: portrait|landscape}", has_doc, orientation),
|
||||
cmd!("pageLayout.size", "Size", ["Page Layout", "Page Setup"], None, "{paper: Letter|Legal|A4|A3|A5|Tabloid|Executive}", has_doc, paper),
|
||||
cmd!(
|
||||
"pageLayout.size",
|
||||
"Size",
|
||||
["Page Layout", "Page Setup"],
|
||||
None,
|
||||
"{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)}",
|
||||
has_doc,
|
||||
paper
|
||||
),
|
||||
cmd!(
|
||||
"pageLayout.margins",
|
||||
"Margins",
|
||||
@@ -113,23 +132,28 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
|
||||
fn freeze_panes(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let si = target_sheet(s, p)?;
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
|
||||
if sh.freeze.is_some() && p.get("cell").is_none() {
|
||||
return set_freeze(s, None);
|
||||
return set_freeze(s, si, None);
|
||||
}
|
||||
let at = cell_param(s, p, "cell")?.unwrap_or(d.selection.active);
|
||||
let at = match cell_param_on(s, p, "cell", si)? {
|
||||
Some(c) => c,
|
||||
None if si == d.wb.active_sheet => d.selection.active,
|
||||
// The selection belongs to the active sheet.
|
||||
None => return Err(bad("view.freezePanes", "missing `cell`: the selection is on the active sheet")),
|
||||
};
|
||||
let top = sh.view_top_left;
|
||||
let rows = at.row.saturating_sub(top.row);
|
||||
let cols = at.col.saturating_sub(top.col);
|
||||
let _ = (rows, cols);
|
||||
let (rows, cols) = (at.row, at.col);
|
||||
set_freeze(s, if rows == 0 && cols == 0 { None } else { Some((rows, cols)) })
|
||||
set_freeze(s, si, if rows == 0 && cols == 0 { None } else { Some((rows, cols)) })
|
||||
}
|
||||
|
||||
fn set_freeze(s: &mut Session, f: Option<(u32, u32)>) -> Result<Json> {
|
||||
fn set_freeze(s: &mut Session, si: usize, f: Option<(u32, u32)>) -> Result<Json> {
|
||||
edit(s, |cx| {
|
||||
let si = cx.sheet_index();
|
||||
cx.sheet_mut(si)?.freeze = f;
|
||||
Ok(json!({"freeze": f}))
|
||||
})
|
||||
@@ -143,6 +167,7 @@ fn sheet_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
|
||||
"gridlines" => &mut sh.show_gridlines,
|
||||
"headings" => &mut sh.show_headings,
|
||||
"zeros" => &mut sh.show_zeros,
|
||||
"outlineSymbols" => &mut sh.show_outline_symbols,
|
||||
"rtl" => &mut sh.right_to_left,
|
||||
"printGridlines" => &mut sh.print.gridlines,
|
||||
_ => &mut sh.print.headings,
|
||||
@@ -152,6 +177,16 @@ fn sheet_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Ctrl+6: hides every object, or shows them again. Placeholders (from a file) count as shown.
|
||||
fn show_objects(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
use gridcraft_model::ShowObjects;
|
||||
edit(s, |cx| {
|
||||
let on = bool_param(p, "on").unwrap_or(cx.wb.show_objects == ShowObjects::None);
|
||||
cx.wb.show_objects = if on { ShowObjects::All } else { ShowObjects::None };
|
||||
Ok(json!({"on": on}))
|
||||
})
|
||||
}
|
||||
|
||||
fn zoom(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let z = f64_param(p, "percent").ok_or_else(|| bad("view.zoom", "missing `percent`"))?.clamp(10.0, 400.0) as u16;
|
||||
let d = s.doc_mut()?;
|
||||
@@ -232,13 +267,31 @@ fn print_area(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
|
||||
fn print_titles(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let rows = str_param(p, "rows").and_then(RangeRef::parse).map(|r| (r.start.row, r.end.row));
|
||||
let cols = str_param(p, "cols").and_then(RangeRef::parse).map(|r| (r.start.col, r.end.col));
|
||||
// An absent key leaves that axis alone, and an empty or null value clears it — the same
|
||||
// reading `pageLayout.pageSetup` gives `titleRows`/`titleCols`. Assigning `None` for a
|
||||
// key that was never sent used to wipe the other axis.
|
||||
let axis = |key: &str, is_cols: bool| -> Result<Option<Option<(u32, u32)>>> {
|
||||
match p.get(key) {
|
||||
None => Ok(None),
|
||||
Some(Json::Null) => Ok(Some(None)),
|
||||
Some(Json::String(t)) if t.trim().is_empty() => Ok(Some(None)),
|
||||
Some(Json::String(t)) => match RangeRef::parse(t) {
|
||||
Some(r) => Ok(Some(Some(if is_cols { (r.start.col, r.end.col) } else { (r.start.row, r.end.row) }))),
|
||||
None => Err(bad("pageLayout.printTitles", format!("`{key}` is not a range: `{t}`"))),
|
||||
},
|
||||
Some(_) => Err(bad("pageLayout.printTitles", format!("`{key}` must be text like \"1:2\" or \"A:A\""))),
|
||||
}
|
||||
};
|
||||
let (rows, cols) = (axis("rows", false)?, axis("cols", true)?);
|
||||
edit(s, |cx| {
|
||||
let si = cx.sheet_index();
|
||||
let pr = &mut cx.sheet_mut(si)?.print;
|
||||
pr.title_rows = rows;
|
||||
pr.title_cols = cols;
|
||||
if let Some(t) = rows {
|
||||
pr.title_rows = t;
|
||||
}
|
||||
if let Some(t) = cols {
|
||||
pr.title_cols = t;
|
||||
}
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
||||
+104
-34
@@ -70,7 +70,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Advanced…",
|
||||
["Data", "Sort & Filter"],
|
||||
None,
|
||||
"{range? (list with headers; default current region), criteria: \"H1:I3\" (header row of field names; rows OR'ed, columns AND'ed; \">100\", \"=abc\", \"a*\", \"<>\"; blank-headed formula cells are computed criteria for the first data row), copyTo?: \"K1\" (else filter in place by hiding rows), unique?: bool}",
|
||||
"{range? (list with headers; default current region), criteria?: \"H1:I3\" (omitted/blank matches all rows; header row of field names; rows OR'ed, columns AND'ed; \">100\", \"=abc\", \"a*\", \"<>\"; blank-headed formula cells are computed criteria for the first data row), copyTo?: \"K1\" (else filter in place by hiding rows), unique?: bool}",
|
||||
has_doc,
|
||||
advanced_filter
|
||||
),
|
||||
@@ -488,8 +488,15 @@ fn scenario_show(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (_, cells) = cell_list(&d.wb, &sc.changing, CMD, MAX_SCENARIO_CELLS)?;
|
||||
edit(s, |cx| {
|
||||
for (c, v) in cells.iter().zip(sc.values.iter()) {
|
||||
let input = super::edit::json_to_input(v);
|
||||
let cell = super::edit::input_to_cell(&input, si, *c, &mut cx.wb)?;
|
||||
// Stored numbers are values, not typed text: entering them through the parser would
|
||||
// apply percent entry (9 -> 9%) to a Percentage-formatted cell.
|
||||
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 })
|
||||
}
|
||||
None => super::edit::input_to_cell(&super::edit::json_to_input(v), si, *c, &mut cx.wb)?,
|
||||
};
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
match cell {
|
||||
Some(cell) => sh.set_cell(*c, cell),
|
||||
@@ -795,41 +802,45 @@ fn advanced_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
(d.wb.active_sheet, crate::selection::current_region(sh, d.selection.active))
|
||||
}
|
||||
};
|
||||
let (csi, crit) = range_arg(s, p, "criteria", CMD)?;
|
||||
let criteria = str_param(p, "criteria").filter(|t| !t.trim().is_empty()).map(|t| parse_range(&d.wb, t, CMD)).transpose()?;
|
||||
if list.height() < 1 || list.count() > 50_000_000 {
|
||||
return Err(bad(CMD, "the list range is too large"));
|
||||
}
|
||||
if crit.width() > 1024 || crit.height() > 10_000 {
|
||||
if let Some((_, crit)) = criteria
|
||||
&& (crit.width() > 1024 || crit.height() > 10_000)
|
||||
{
|
||||
return Err(bad(CMD, "the criteria range is too large"));
|
||||
}
|
||||
let wb = &d.wb;
|
||||
let sh = wb.sheet(si).ok_or(EngineError::NoDocument)?;
|
||||
let csh = wb.sheet(csi).ok_or(EngineError::NoDocument)?;
|
||||
let headers: Vec<String> =
|
||||
(list.start.col..=list.end.col).map(|c| crate::display::cell_text(wb, sh, CellRef::new(list.start.row, c)).trim().to_lowercase()).collect();
|
||||
let first_data = list.start.row + 1;
|
||||
// Criteria rows → conditions.
|
||||
let mut rows: Vec<Vec<Cond>> = Vec::new();
|
||||
for cr in crit.start.row + 1..=crit.end.row {
|
||||
let mut conds = Vec::new();
|
||||
for cc in crit.start.col..=crit.end.col {
|
||||
let at = CellRef::new(cr, cc);
|
||||
let cell = csh.cell(at);
|
||||
let v = csh.value(at);
|
||||
let formula = cell.and_then(|c| c.formula.as_ref());
|
||||
if v.is_empty() && formula.is_none() {
|
||||
continue;
|
||||
}
|
||||
let head = crate::display::cell_text(wb, csh, CellRef::new(crit.start.row, cc)).trim().to_lowercase();
|
||||
match headers.iter().position(|h| !head.is_empty() && *h == head) {
|
||||
Some(off) => conds.push(Cond::Compare(off as u32, v)),
|
||||
None => match formula.and_then(|f| f.expr()) {
|
||||
Some(e) => conds.push(Cond::Computed(e, at)),
|
||||
None => return Err(EngineError::Other(format!("The criteria label `{head}` doesn't match a column of the list."))),
|
||||
},
|
||||
if let Some((csi, crit)) = criteria {
|
||||
let csh = wb.sheet(csi).ok_or(EngineError::NoDocument)?;
|
||||
for cr in crit.start.row + 1..=crit.end.row {
|
||||
let mut conds = Vec::new();
|
||||
for cc in crit.start.col..=crit.end.col {
|
||||
let at = CellRef::new(cr, cc);
|
||||
let cell = csh.cell(at);
|
||||
let v = csh.value(at);
|
||||
let formula = cell.and_then(|c| c.formula.as_ref());
|
||||
if v.is_empty() && formula.is_none() {
|
||||
continue;
|
||||
}
|
||||
let head = crate::display::cell_text(wb, csh, CellRef::new(crit.start.row, cc)).trim().to_lowercase();
|
||||
match headers.iter().position(|h| !head.is_empty() && *h == head) {
|
||||
Some(off) => conds.push(Cond::Compare(off as u32, v)),
|
||||
None => match formula.and_then(|f| f.expr()) {
|
||||
Some(e) => conds.push(Cond::Computed(e, at)),
|
||||
None => return Err(EngineError::Other(format!("The criteria label `{head}` doesn't match a column of the list."))),
|
||||
},
|
||||
}
|
||||
}
|
||||
rows.push(conds);
|
||||
}
|
||||
rows.push(conds);
|
||||
}
|
||||
let matches_row = |row: u32| -> bool {
|
||||
if rows.is_empty() {
|
||||
@@ -845,7 +856,7 @@ fn advanced_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
Cond::Computed(e, at) => {
|
||||
let shifted = gridcraft_formula::adjust::shift_relative(e.clone(), row as i64 - first_data as i64, 0);
|
||||
let v = gridcraft_calc::recalc::evaluate_expr(wb, csi, *at, &shifted);
|
||||
let v = gridcraft_calc::recalc::evaluate_expr(wb, criteria.map(|(si, _)| si).unwrap_or(si), *at, &shifted);
|
||||
matches!(v.scalar().to_bool(), Ok(true))
|
||||
}
|
||||
})
|
||||
@@ -867,28 +878,30 @@ fn advanced_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if let Some(t) = str_param(p, "copyTo") {
|
||||
let (dsi, dest) = parse_range(wb, t, CMD)?;
|
||||
let dest = dest.start;
|
||||
let extract = RangeRef::new(dest, dest.offset_clamped(list.height() as i64 - 1, list.width() as i64 - 1));
|
||||
if dsi == si && extract.intersects(&list) {
|
||||
return Err(EngineError::Other("The extract range can't overlap the list range.".into()));
|
||||
}
|
||||
let mut rows_out: Vec<u32> = vec![list.start.row];
|
||||
rows_out.extend(keep.iter().filter(|(_, k)| *k).map(|(r, _)| *r));
|
||||
let end =
|
||||
dest.offset(rows_out.len() as i64 - 1, list.width() as i64 - 1).ok_or_else(|| bad(CMD, "the extract range exceeds the worksheet"))?;
|
||||
let out_range = RangeRef::new(dest, end);
|
||||
if dsi == si && out_range.intersects(&list) {
|
||||
return Err(EngineError::Other("The extract range can't overlap the list range.".into()));
|
||||
}
|
||||
let cells: Vec<(CellRef, Cell)> = rows_out
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(|(i, row)| {
|
||||
(list.start.col..=list.end.col).filter_map(move |c| {
|
||||
(list.start.col..=list.end.col).map(move |c| {
|
||||
let src = CellRef::new(*row, c);
|
||||
let to = dest.offset(i as i64, (c - list.start.col) as i64)?;
|
||||
// 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);
|
||||
Some((to, Cell { value: sh.value(src), formula: None, style }))
|
||||
(to, Cell { value: sh.value(src), formula: None, style })
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let out_range = RangeRef::new(dest, dest.offset_clamped(rows_out.len() as i64 - 1, list.width() as i64 - 1));
|
||||
edit(s, |cx| {
|
||||
let shm = cx.sheet_mut(dsi)?;
|
||||
let old: Vec<CellRef> = shm.cells.iter_range(extract).map(|(c, _)| c).collect();
|
||||
let old: Vec<CellRef> = shm.cells.iter_range(out_range).map(|(c, _)| c).collect();
|
||||
for c in &old {
|
||||
shm.remove_cell(*c);
|
||||
}
|
||||
@@ -1199,6 +1212,17 @@ mod tests {
|
||||
assert!(!r.converged);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_show_restores_a_percentage_cells_stored_number() {
|
||||
let mut s = s();
|
||||
s.execute("home.numberFormat", json!({"range": "B2", "format": "Percentage"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "5"})).unwrap();
|
||||
s.execute("data.scenarioAdd", json!({"name": "Low", "changing": "B2"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B2", "input": "9"})).unwrap();
|
||||
s.execute("data.scenarioShow", json!({"name": "Low"})).unwrap();
|
||||
assert_eq!(n(&s, "B2"), 0.05);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenarios() {
|
||||
let mut s = s();
|
||||
@@ -1317,6 +1341,52 @@ mod tests {
|
||||
assert!(s.execute("data.advancedFilter", json!({"range": "A1:B6", "criteria": "H1:I3", "copyTo": "B2"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_filter_copy_preserves_cells_beyond_result() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Score"], ["A", 1], ["B", 2], ["C", 3]]})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "H1", "values": [["Name"], ["B"]]})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "K4", "input": "Keep me"})).unwrap();
|
||||
let r = s.execute("data.advancedFilter", json!({"range": "A1:B4", "criteria": "H1:H2", "copyTo": "K1"})).unwrap();
|
||||
assert_eq!(r["range"], "K1:L2");
|
||||
assert_eq!(v(&s, "K2"), Value::from("B"));
|
||||
assert_eq!(v(&s, "K4"), Value::from("Keep me"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_filter_copy_rejects_overflow_without_mutation() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Name", "Score"], ["A", 1]]})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "H1", "values": [["Name"], ["A"]]})).unwrap();
|
||||
for dest in ["XFD1", "K1048576"] {
|
||||
s.execute("cell.set", json!({"cell": dest, "input": "Keep me"})).unwrap();
|
||||
assert!(s.execute("data.advancedFilter", json!({"range": "A1:B2", "criteria": "H1:H2", "copyTo": dest})).is_err());
|
||||
assert_eq!(v(&s, dest), Value::from("Keep me"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_filter_copy_allows_output_above_source_when_only_old_clear_area_overlaps() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "C4", "values": [["Name", "Score"], ["A", 1], ["B", 2], ["C", 3]]})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "H1", "values": [["Name"], ["A"]]})).unwrap();
|
||||
let r = s.execute("data.advancedFilter", json!({"range": "C4:D7", "criteria": "H1:H2", "copyTo": "B1"})).unwrap();
|
||||
assert_eq!(r["range"], "B1:C2");
|
||||
assert_eq!(v(&s, "C4"), Value::from("Name"));
|
||||
assert_eq!(v(&s, "C2"), Value::Number(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_filter_unique_without_criteria() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Name"], ["A"], ["A"], ["B"]]})).unwrap();
|
||||
let r = s.execute("data.advancedFilter", json!({"range": "A1:A4", "copyTo": "C1", "unique": true})).unwrap();
|
||||
assert_eq!(r["matched"], 2);
|
||||
assert_eq!(r["range"], "C1:C3");
|
||||
assert_eq!(v(&s, "C2"), Value::from("A"));
|
||||
assert_eq!(v(&s, "C3"), Value::from("B"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn criteria_semantics() {
|
||||
use super::criterion_matches as m;
|
||||
|
||||
@@ -33,6 +33,25 @@ pub fn format(v: &Value, code: &str, wb: &Workbook) -> Formatted {
|
||||
format_value(v, &parsed(code), wb.date_system)
|
||||
}
|
||||
|
||||
/// Whether typing `t` back into a General cell would not give the text `t` again (it would parse
|
||||
/// as a number, boolean, error, date or formula, or lose a leading apostrophe). Such text is shown
|
||||
/// for editing with a leading `'`, as Excel does for text carrying a quote prefix, so that
|
||||
/// committing the editor unchanged keeps the cell as text.
|
||||
fn text_needs_quote_prefix(t: &str, sys: gridcraft_core::date::DateSystem) -> bool {
|
||||
if t.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut chars = t.chars();
|
||||
let (first, second) = (chars.next(), chars.next());
|
||||
if matches!(first, Some('=' | '\'')) {
|
||||
return true;
|
||||
}
|
||||
if matches!(first, Some('+' | '-')) && second.is_some_and(|c| c.is_ascii_alphabetic() || c == '(') {
|
||||
return true;
|
||||
}
|
||||
!matches!(gridcraft_core::parse::parse_input(t, sys).value, Value::Text(ref x) if x.as_ref() == t)
|
||||
}
|
||||
|
||||
/// What the cell editor and the formula bar start from. Same as `Cell::input_text`, except that a
|
||||
/// constant number in a date or time format is shown as a date (as the grid shows it), not as its
|
||||
/// serial. When the formatted text would not read back as the same date (for example
|
||||
@@ -41,6 +60,13 @@ pub fn format(v: &Value, code: &str, wb: &Workbook) -> Formatted {
|
||||
pub fn edit_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String {
|
||||
let raw = sheet.input_text(c);
|
||||
let Some(cell) = sheet.cell(c) else { return raw };
|
||||
if let Value::Text(t) = &cell.value
|
||||
&& cell.formula.is_none()
|
||||
&& wb.styles.get(cell.style).num_fmt.as_str() != "@"
|
||||
&& text_needs_quote_prefix(t, wb.date_system)
|
||||
{
|
||||
return format!("'{raw}");
|
||||
}
|
||||
let Value::Number(n) = &cell.value else { return raw };
|
||||
let code = wb.styles.get(cell.style).num_fmt.as_str();
|
||||
if cell.formula.is_some() || !parsed(code).is_date() {
|
||||
@@ -95,7 +121,13 @@ pub fn cell_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String {
|
||||
/// and no General narrowing (Save As CSV/Text writes this). Formulas give their result.
|
||||
pub fn value_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String {
|
||||
let style = wb.styles.get(sheet.style_id(c));
|
||||
let f = format(&sheet.value(c), style.num_fmt.as_str(), wb);
|
||||
value_text_as(wb, &sheet.value(c), style.num_fmt.as_str())
|
||||
}
|
||||
|
||||
/// [`value_text`] for a value and a format code given apart from a cell (print takes the code
|
||||
/// from the cell's effective look, which a conditional format can override).
|
||||
pub fn value_text_as(wb: &Workbook, v: &Value, code: &str) -> String {
|
||||
let f = format(v, code, wb);
|
||||
match f.fill {
|
||||
Some((_, pos)) => {
|
||||
let mut t = f.text;
|
||||
|
||||
+118
-13
@@ -19,7 +19,17 @@ pub enum FillMode {
|
||||
Alternate,
|
||||
Copy,
|
||||
Formats,
|
||||
/// Fill Without Formatting: extends like a plain drag, the targets keep their formats.
|
||||
ValuesOnly,
|
||||
/// Fill Series from the right-drag menu: extends like a plain drag, and a lone number counts up.
|
||||
Extend,
|
||||
}
|
||||
|
||||
impl FillMode {
|
||||
/// The modes that continue the source's series rather than copy it.
|
||||
fn extends_series(self) -> bool {
|
||||
matches!(self, FillMode::Series | FillMode::Extend | FillMode::ValuesOnly)
|
||||
}
|
||||
}
|
||||
|
||||
/// Built-in custom lists.
|
||||
@@ -35,6 +45,18 @@ fn lists() -> Vec<Vec<String>> {
|
||||
]
|
||||
}
|
||||
|
||||
/// The most cells one fill may write: the size cap the fill commands put on their ranges.
|
||||
pub const MAX_FILL_CELLS: u64 = 5_000_000;
|
||||
|
||||
/// Whether `target` is a shape Excel's AutoFill accepts for `src`: it contains the source and the
|
||||
/// source sits in one of its corners. Excel refuses a target that doesn't contain the source
|
||||
/// (`2:3` into `A:A`, `H4:H9` into `D:F`) or holds it away from every corner (`H4:H9` into `H:H`).
|
||||
pub fn holds_source_in_corner(src: RangeRef, target: RangeRef) -> bool {
|
||||
target.contains_range(&src)
|
||||
&& (target.start.row == src.start.row || target.end.row == src.end.row)
|
||||
&& (target.start.col == src.start.col || target.end.col == src.end.col)
|
||||
}
|
||||
|
||||
/// Fills `target` (which contains `src` at one edge) from the pattern in `src`.
|
||||
pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: FillMode) -> Result<()> {
|
||||
let custom = cx.wb.custom_lists.clone();
|
||||
@@ -46,6 +68,18 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
|
||||
} else {
|
||||
target.end.col > src.end.col || target.start.col == src.start.col
|
||||
};
|
||||
// Every source lane writes one cell per step past the source: bound that product, not the
|
||||
// source and target separately (whole rows into whole columns was ~17 billion writes).
|
||||
let (lane_count, steps) = if vertical {
|
||||
let steps = if forward { target.end.row.saturating_sub(src.end.row) } else { src.start.row.saturating_sub(target.start.row) };
|
||||
(src.width(), steps)
|
||||
} else {
|
||||
let steps = if forward { target.end.col.saturating_sub(src.end.col) } else { src.start.col.saturating_sub(target.start.col) };
|
||||
(src.height(), steps)
|
||||
};
|
||||
if u64::from(lane_count).saturating_mul(u64::from(steps)) > MAX_FILL_CELLS {
|
||||
return Err(crate::EngineError::Other("The fill is too large.".into()));
|
||||
}
|
||||
let lanes: Vec<u32> = if vertical { (src.start.col..=src.end.col).collect() } else { (src.start.row..=src.end.row).collect() };
|
||||
let src_len = if vertical { src.height() } else { src.width() };
|
||||
let mut writes: Vec<(CellRef, Option<Cell>)> = Vec::new();
|
||||
@@ -67,15 +101,18 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
|
||||
cols.into_iter().map(|c| CellRef::new(lane, c)).collect()
|
||||
};
|
||||
let fmt = |c: &(CellRef, Option<Cell>)| cx.wb.styles.get(c.1.as_ref().map(|c| c.style).unwrap_or_default()).num_fmt.as_str();
|
||||
let pattern = if matches!(mode, FillMode::Series | FillMode::Alternate) {
|
||||
let pattern = if mode.extends_series() || mode == FillMode::Alternate {
|
||||
let detected = Pattern::detect(&src_cells, &custom, cx.wb.date_system, src_cells.first().map(fmt).unwrap_or_default());
|
||||
if mode == FillMode::Alternate { detected.alternate(&src_cells) } else { detected }
|
||||
match (mode, detected) {
|
||||
(FillMode::Alternate, p) | (FillMode::Extend, p @ Pattern::Copy) => p.alternate(&src_cells),
|
||||
(_, p) => p,
|
||||
}
|
||||
} else {
|
||||
Pattern::Copy
|
||||
};
|
||||
// A source with no common pattern (`Product 1`, `On backorder`): each cell continues its
|
||||
// own series, one step per repeat of the source.
|
||||
let per_cell: Vec<Pattern> = if mode == FillMode::Series && matches!(pattern, Pattern::Copy) && src_cells.len() > 1 {
|
||||
let per_cell: Vec<Pattern> = if mode.extends_series() && matches!(pattern, Pattern::Copy) && src_cells.len() > 1 {
|
||||
src_cells.iter().map(|c| Pattern::detect(std::slice::from_ref(c), &custom, cx.wb.date_system, fmt(c))).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
@@ -93,7 +130,7 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
|
||||
writes.push((*t, Some(existing)));
|
||||
continue;
|
||||
}
|
||||
FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy => {
|
||||
FillMode::ValuesOnly | FillMode::Series | FillMode::Alternate | FillMode::Copy | FillMode::Extend => {
|
||||
let style = if mode == FillMode::ValuesOnly { sh.cell(*t).map(|c| c.style).unwrap_or_default() } else { cell.style };
|
||||
if let Some(f) = &cell.formula {
|
||||
if let Some(e) = f.expr() {
|
||||
@@ -165,6 +202,7 @@ enum Pattern {
|
||||
last: f64,
|
||||
first: f64,
|
||||
months: i64,
|
||||
sys: gridcraft_core::DateSystem,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -199,7 +237,7 @@ impl Pattern {
|
||||
&& (a.month != b.month || a.year != b.year)
|
||||
{
|
||||
let months = (b.year as i64 - a.year as i64) * 12 + b.month as i64 - a.month as i64;
|
||||
return Pattern::Months { last, first, months };
|
||||
return Pattern::Months { last, first, months, sys };
|
||||
}
|
||||
}
|
||||
// Least-squares step for 2+ values (Excel uses a linear trend).
|
||||
@@ -273,12 +311,15 @@ impl Pattern {
|
||||
}
|
||||
Pattern::TextNum { prefix, suffix, last, first, step, width } => {
|
||||
let base = if forward { *last } else { *first };
|
||||
let n = base + step * k * sign;
|
||||
// Text numbers come from input and can sit near i64::MAX (`X 9223372036854775807`):
|
||||
// an overflowing series yields nothing — the cell keeps its source value — rather
|
||||
// than wrapping in release or panicking in debug.
|
||||
let n = step.checked_mul(k)?.checked_mul(sign)?.checked_add(base)?;
|
||||
// `Qtr3`, `Quarter 3` wrap after 4 like `Q3`.
|
||||
let quarter = suffix.is_empty()
|
||||
&& (1..=4).contains(&base)
|
||||
&& ["q", "qtr", "quarter"].contains(&prefix.trim_end().to_ascii_lowercase().as_str());
|
||||
let n = if quarter { (n - 1).rem_euclid(4) + 1 } else { n.abs() };
|
||||
let n = if quarter { n.checked_sub(1)?.rem_euclid(4) + 1 } else { n.checked_abs()? };
|
||||
Some(Value::text(format!("{prefix}{n:0width$}{}", ordinal_suffix(n, suffix), width = *width)))
|
||||
}
|
||||
Pattern::List { list, last, first, step, upper, lower } => {
|
||||
@@ -297,9 +338,9 @@ impl Pattern {
|
||||
s
|
||||
}))
|
||||
}
|
||||
Pattern::Months { last, first, months } => {
|
||||
Pattern::Months { last, first, months, sys } => {
|
||||
let base = if forward { *last } else { *first };
|
||||
let s = add_months_clamped(gridcraft_core::DateSystem::D1900, base, months.checked_mul(k * sign)?)?;
|
||||
let s = add_months_clamped(*sys, base, months.checked_mul(k * sign)?)?;
|
||||
Some(Value::number(s))
|
||||
}
|
||||
}
|
||||
@@ -442,13 +483,13 @@ pub fn flash_fill(cx: &mut Ctx, sheet: usize, at: CellRef) -> Result<usize> {
|
||||
// Header row: skip a first row whose target is text and source looks like a header? Keep simple.
|
||||
let mut examples = Vec::new();
|
||||
let mut todo = Vec::new();
|
||||
// Flash Fill works on the text cells show (a date as formatted, not its serial number).
|
||||
for r in top..=bottom {
|
||||
let src = sh.value(CellRef::new(r, src_col)).display();
|
||||
let dst = sh.value(CellRef::new(r, at.col));
|
||||
if dst.is_empty() {
|
||||
let src = crate::display::cell_text(&cx.wb, sh, CellRef::new(r, src_col));
|
||||
if sh.value(CellRef::new(r, at.col)).is_empty() {
|
||||
todo.push((r, src));
|
||||
} else {
|
||||
examples.push((src, dst.display()));
|
||||
examples.push((src, crate::display::cell_text(&cx.wb, sh, CellRef::new(r, at.col))));
|
||||
}
|
||||
}
|
||||
if examples.is_empty() {
|
||||
@@ -603,6 +644,18 @@ fn synthesize(src: &str, dst: &str) -> Vec<Program> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn flash_fill_reads_shown_text() {
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
let values = serde_json::json!([["2024-01-15", "15/01/2024"], ["2023-12-03", null]]);
|
||||
s.execute("range.setValues", serde_json::json!({"range": "A1", "values": values})).unwrap();
|
||||
s.execute("selection.set", serde_json::json!({"cell": "B2"})).unwrap();
|
||||
s.execute("data.flashFill", serde_json::json!({})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sh.value(CellRef::new(1, 1)), Value::from("03/12/2023"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flash_programs() {
|
||||
let p = learn(&[("John Smith".into(), "Smith, John".into())]).unwrap();
|
||||
@@ -652,4 +705,56 @@ mod tests {
|
||||
let growth: Vec<String> = (1..6).map(|r| crate::display::cell_text(&d.wb, sh, CellRef::new(r, 0))).collect();
|
||||
assert_eq!(growth, ["2.5", "1.25", "", "", ""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_series_near_i64_max_neither_wraps_nor_panics() {
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", serde_json::json!({"cell": "A1", "input": "X 0"})).unwrap();
|
||||
s.execute("cell.set", serde_json::json!({"cell": "A2", "input": "X 9223372036854775807"})).unwrap();
|
||||
// The detected step is i64::MAX, so the next value overflows i64. The cell keeps its
|
||||
// source value instead of panicking in debug or wrapping in release.
|
||||
s.execute("edit.autoFill", serde_json::json!({"source": "A1:A2", "target": "A1:A3"})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.active().unwrap();
|
||||
assert_eq!(crate::display::cell_text(&d.wb, sh, CellRef::new(2, 0)), "X 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autofill_refuses_targets_that_do_not_extend_the_source() {
|
||||
// Whole rows into whole columns once filled every source column down the whole sheet
|
||||
// (~17 billion writes) and aborted the process on a 24-51 GB allocation (fuzz seeds 1064,
|
||||
// 1114, 1775). Excel refuses every one of these shapes.
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", serde_json::json!({"range": "A2", "values": [[1, "Item 1"], [2, "Item 2"]]})).unwrap();
|
||||
s.execute("range.setValues", serde_json::json!({"range": "H4", "values": [[10], [20]]})).unwrap();
|
||||
let before = s.doc().unwrap().wb.active().unwrap().cells.len();
|
||||
for (mode, source, target) in [
|
||||
("series", "2:3", "A:A"),
|
||||
("alternate", "2:3", "A:A"),
|
||||
("series", "8:10", "C:E"),
|
||||
("copy", "24:24", "B:D"),
|
||||
("values", "H4:H9", "D:F"),
|
||||
("series", "H4:H9", "H:H"),
|
||||
("series", "A2:A3", "A1:A5"),
|
||||
("series", "A2:A6", "A2:A4"),
|
||||
("series", "B2:B3", "A2:A3"),
|
||||
] {
|
||||
let r = s.execute("edit.autoFill", serde_json::json!({"mode": mode, "source": source, "target": target}));
|
||||
assert!(r.is_err(), "{source} -> {target} should be refused");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().cells.len(), before, "a refused fill writes nothing");
|
||||
// Targets holding the source in a corner still fill, whole rows and diagonal drags included.
|
||||
s.execute("edit.autoFill", serde_json::json!({"source": "2:3", "target": "2:6"})).unwrap();
|
||||
s.execute("edit.autoFill", serde_json::json!({"source": "A2:A3", "target": "A1:A3"})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
let text = |a: &str| crate::display::cell_text(&s.doc().unwrap().wb, sh, CellRef::parse(a).unwrap());
|
||||
assert_eq!([text("A1"), text("A6"), text("B6")], ["0", "5", "Item 5"]);
|
||||
assert!(holds_source_in_corner(RangeRef::parse("B5").unwrap(), RangeRef::parse("B3:B5").unwrap()));
|
||||
assert!(holds_source_in_corner(RangeRef::parse("A:A").unwrap(), RangeRef::parse("A:C").unwrap()));
|
||||
assert!(holds_source_in_corner(RangeRef::parse("B5:C9").unwrap(), RangeRef::parse("B5:D13").unwrap()));
|
||||
assert!(holds_source_in_corner(RangeRef::parse("B1:B2").unwrap(), RangeRef::parse("A1:B4").unwrap()));
|
||||
assert!(!holds_source_in_corner(RangeRef::parse("A:A").unwrap(), RangeRef::parse("1:1").unwrap()));
|
||||
}
|
||||
}
|
||||
|
||||
+481
-30
@@ -55,8 +55,15 @@ pub fn open_bytes(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
|
||||
/// [`open_bytes`], first decrypting a password-protected workbook with `password`. Errors never
|
||||
/// quote the password.
|
||||
pub fn open_bytes_with_password(name: &str, bytes: &[u8], password: Option<&str>) -> Result<(Workbook, Vec<String>)> {
|
||||
open_bytes_with(name, bytes, password, false)
|
||||
}
|
||||
|
||||
/// [`open_bytes_with_password`], and with `csv_as_text` a CSV/TSV/TXT file keeps every field as
|
||||
/// the text it holds, with no number, date or boolean conversion (identifiers such as GTINs, SKUs
|
||||
/// and ZIP codes survive). It does not apply to other formats.
|
||||
pub fn open_bytes_with(name: &str, bytes: &[u8], password: Option<&str>, csv_as_text: bool) -> Result<(Workbook, Vec<String>)> {
|
||||
if gridcraft_xlsx::sniff(bytes) != gridcraft_xlsx::Format::Encrypted {
|
||||
let (mut wb, warnings) = open_plain(name, bytes)?;
|
||||
let (mut wb, warnings) = open_plain(name, bytes, csv_as_text)?;
|
||||
fit_imported_rows(&mut wb);
|
||||
return Ok((wb, warnings));
|
||||
}
|
||||
@@ -70,7 +77,7 @@ pub fn open_bytes_with_password(name: &str, bytes: &[u8], password: Option<&str>
|
||||
if gridcraft_xlsx::sniff(&plain) == gridcraft_xlsx::Format::Encrypted {
|
||||
return Err(EngineError::Other(format!("We can't open '{name}': the encrypted file is damaged (it holds another encrypted file).")));
|
||||
}
|
||||
let (mut wb, mut warnings) = open_plain(name, &plain)?;
|
||||
let (mut wb, mut warnings) = open_plain(name, &plain, csv_as_text)?;
|
||||
fit_imported_rows(&mut wb);
|
||||
warnings.insert(0, DECRYPTED_NOTICE.into());
|
||||
Ok((wb, warnings))
|
||||
@@ -112,7 +119,7 @@ fn decrypt_message(name: &str, e: gridcraft_xlsx::DecryptError) -> String {
|
||||
}
|
||||
|
||||
/// Parses bytes that aren't encrypted.
|
||||
fn open_plain(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
|
||||
fn open_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 {
|
||||
@@ -140,7 +147,12 @@ fn open_plain(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
|
||||
}
|
||||
// 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| EngineError::Other(format!("We can't open '{name}': {e}")))?;
|
||||
let (wb, report) = gridcraft_xlsx::read_xlsx(bytes).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}"),
|
||||
})
|
||||
})?;
|
||||
return Ok((wb, report.warnings));
|
||||
}
|
||||
match kind {
|
||||
@@ -150,10 +162,17 @@ fn open_plain(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
|
||||
if wb.sheets.is_empty() {
|
||||
wb = Workbook::new();
|
||||
}
|
||||
Ok((wb, vec![]))
|
||||
// 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).
|
||||
let dropped: usize = wb.sheets.iter_mut().map(|sh| std::sync::Arc::make_mut(sh).cells.rebuild()).sum();
|
||||
let mut warnings = Vec::new();
|
||||
if dropped > 0 {
|
||||
warnings.push(format!("{name}: {dropped} cells outside the 1,048,576 × 16,384 grid were dropped"));
|
||||
}
|
||||
Ok((wb, warnings))
|
||||
}
|
||||
Some(FileKind::Tsv) => {
|
||||
let opts = gridcraft_xlsx::CsvOptions { delimiter: b'\t', ..Default::default() };
|
||||
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()))?;
|
||||
// Tab is what makes it a .tsv/.txt; saved as .csv it gets the CSV default.
|
||||
wb.csv_delimiter = None;
|
||||
@@ -161,7 +180,7 @@ fn open_plain(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
|
||||
Ok((wb, vec![]))
|
||||
}
|
||||
_ => {
|
||||
let opts = gridcraft_xlsx::CsvOptions { delimiter: 0, ..Default::default() };
|
||||
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()))?;
|
||||
rename_first_sheet(&mut wb, name);
|
||||
Ok((wb, vec![]))
|
||||
@@ -169,6 +188,25 @@ fn open_plain(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// What to tell the user when one part of an XLSX package unpacks to more than the reader's
|
||||
/// per-part limit: the sizes, why the limit is there, and what gets the data in. The limit is
|
||||
/// per part, so smaller sheets fit; the shared strings are one part for every sheet, so only a
|
||||
/// CSV, which has no parts, helps there.
|
||||
fn part_too_large_message(name: &str, part: &str, size: u64) -> String {
|
||||
let limit = gridcraft_xlsx::MAX_PART >> 20;
|
||||
let mb = size >> 20;
|
||||
let size = if mb > limit { format!("{mb} MB") } else { format!("more than {limit} MB") };
|
||||
let remedy = if part.contains("worksheets/") {
|
||||
"To open it, split the data across several sheets, since the limit applies to each part, or save the sheet as CSV, which keeps only its values."
|
||||
} else {
|
||||
"To open it, save the data as CSV, which keeps only its values."
|
||||
};
|
||||
format!(
|
||||
"We can't open '{name}': its part {part} unpacks to {size}, over GridCraft's limit of {limit} MB for one part \
|
||||
(a guard against damaged or malicious files). {remedy}"
|
||||
)
|
||||
}
|
||||
|
||||
/// A CSV opens as a sheet named after the file (like Excel).
|
||||
fn rename_first_sheet(wb: &mut Workbook, path: &str) {
|
||||
let stem = std::path::Path::new(path).file_stem().and_then(|s| s.to_str()).unwrap_or("Sheet1");
|
||||
@@ -200,7 +238,10 @@ 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.
|
||||
FileKind::Xlsx => gridcraft_xlsx::write_xlsx_as(wb, gridcraft_xlsx::XlsxKind::from_path(path)).map_err(|e| EngineError::Other(e.to_string())),
|
||||
FileKind::Xlsx => {
|
||||
let book = std::path::Path::new(path).file_name().and_then(|n| n.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()))
|
||||
}
|
||||
FileKind::Xls => {
|
||||
Err(EngineError::Other("Excel 97-2003 (.xls) is supported for import only. Save as .xlsx or another supported export format.".into()))
|
||||
}
|
||||
@@ -228,6 +269,12 @@ pub fn source_dir(path: &str) -> Option<std::path::PathBuf> {
|
||||
std::path::absolute(path).ok()?.parent().map(std::path::Path::to_path_buf)
|
||||
}
|
||||
|
||||
/// The file at `path` as `CELL("filename")` reports it: absolute, so the folder is the one the
|
||||
/// user sees. A workbook that arrived as bytes has no path and keeps only its name.
|
||||
pub fn source_path(path: &str) -> Option<std::path::PathBuf> {
|
||||
std::path::absolute(path).ok()
|
||||
}
|
||||
|
||||
/// Save As CSV/Text writes "the text and values as they are displayed in cells" with "all
|
||||
/// characters in each cell" (Microsoft Support, *Excel formatting and features that are not
|
||||
/// transferred to other file formats*): number formats applied, never column-width `#####`.
|
||||
@@ -247,11 +294,11 @@ fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Result<Vec<u8>> {
|
||||
.map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
fn esc(s: &str) -> String {
|
||||
pub(crate) fn esc(s: &str) -> String {
|
||||
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
|
||||
fn html_cell_css(wb: &Workbook, sh: &Sheet, c: CellRef) -> String {
|
||||
fn html_cell_css(wb: &Workbook, sh: &Sheet, c: CellRef, dir: CellDir) -> String {
|
||||
let st = wb.styles.get(sh.style_id(c));
|
||||
let mut css = String::new();
|
||||
if st.font.bold {
|
||||
@@ -270,16 +317,59 @@ fn html_cell_css(wb: &Workbook, sh: &Sheet, c: CellRef) -> String {
|
||||
}
|
||||
let v = sh.value(c);
|
||||
let align = match st.align.h {
|
||||
gridcraft_model::HAlign::Center => "center",
|
||||
gridcraft_model::HAlign::Right => "right",
|
||||
gridcraft_model::HAlign::Left => "left",
|
||||
_ if v.is_number() => "right",
|
||||
_ => "left",
|
||||
gridcraft_model::HAlign::Center => Some("center"),
|
||||
gridcraft_model::HAlign::Right => Some("right"),
|
||||
gridcraft_model::HAlign::Left => Some("left"),
|
||||
_ if v.is_number() => Some(if sh.right_to_left { "left" } else { "right" }),
|
||||
_ if dir == CellDir::Auto => None,
|
||||
_ => Some("left"),
|
||||
};
|
||||
let _ = write!(css, "text-align:{align};");
|
||||
if let Some(align) = align {
|
||||
let _ = write!(css, "text-align:{align};");
|
||||
}
|
||||
css
|
||||
}
|
||||
|
||||
/// How a cell marks its direction in HTML. Text on a right-to-left sheet, or text with
|
||||
/// right-to-left letters, follows its first strong character (`dir="auto"`), so General
|
||||
/// alignment puts Arabic and Hebrew on the right. Numbers on a right-to-left sheet stay left to
|
||||
/// right so `-5` doesn't read `5-`.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum CellDir {
|
||||
Inherit,
|
||||
Auto,
|
||||
Ltr,
|
||||
}
|
||||
|
||||
impl CellDir {
|
||||
fn of(sh: &Sheet, c: CellRef, text: &str) -> CellDir {
|
||||
if sh.value(c).is_number() {
|
||||
if sh.right_to_left { CellDir::Ltr } else { CellDir::Inherit }
|
||||
} else if sh.right_to_left || text.chars().any(is_right_to_left_letter) {
|
||||
CellDir::Auto
|
||||
} else {
|
||||
CellDir::Inherit
|
||||
}
|
||||
}
|
||||
|
||||
fn attr(self) -> &'static str {
|
||||
match self {
|
||||
CellDir::Inherit => "",
|
||||
CellDir::Auto => " dir=\"auto\"",
|
||||
CellDir::Ltr => " dir=\"ltr\"",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hebrew, Arabic, Syriac, Thaana, N'Ko and their presentation forms.
|
||||
fn is_right_to_left_letter(c: char) -> bool {
|
||||
matches!(c, '\u{0590}'..='\u{08FF}' | '\u{FB1D}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFF}')
|
||||
}
|
||||
|
||||
fn table_dir(sh: &Sheet) -> &'static str {
|
||||
if sh.right_to_left { " dir=\"rtl\"" } else { "" }
|
||||
}
|
||||
|
||||
const CLIPBOARD_HTML_MAX_BYTES: usize = 4 * 1024 * 1024;
|
||||
const CLIPBOARD_HTML_MAX_CELLS: u64 = 10_000;
|
||||
|
||||
@@ -292,7 +382,7 @@ pub(crate) fn range_html(wb: &Workbook, sheet: usize, r: RangeRef, plain_text: &
|
||||
}
|
||||
let sh = wb.sheet(sheet)?;
|
||||
let mut out = ClipboardHtml(String::new());
|
||||
out.write_str("<table style=\"border-collapse:collapse;font-family:Calibri,Carlito,Arial,sans-serif;font-size:11pt\">").ok()?;
|
||||
write!(out, "<table{} style=\"border-collapse:collapse;font-family:Calibri,Carlito,Arial,sans-serif;font-size:11pt\">", table_dir(sh)).ok()?;
|
||||
for row in r.start.row..=r.end.row {
|
||||
if sh.is_row_hidden(row) {
|
||||
continue;
|
||||
@@ -310,11 +400,12 @@ pub(crate) fn range_html(wb: &Workbook, sheet: usize, r: RangeRef, plain_text: &
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let css = html_cell_css(wb, sh, c);
|
||||
write!(out, "<td style=\"border:1px solid #d4d4d4;padding:2px 4px;white-space:pre-wrap;{css}\"{span}>").ok()?;
|
||||
// Use the visible selected cell, even when the merge's original anchor
|
||||
// is outside the range or hidden. This matches the plain-text copy.
|
||||
let text = crate::display::cell_text(wb, sh, c);
|
||||
let dir = CellDir::of(sh, c, &text);
|
||||
let css = html_cell_css(wb, sh, c, dir);
|
||||
write!(out, "<td style=\"border:1px solid #d4d4d4;padding:2px 4px;white-space:pre-wrap;{css}\"{}{span}>", dir.attr()).ok()?;
|
||||
let mut chars = text.chars().peekable();
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
@@ -357,50 +448,149 @@ impl std::fmt::Write for ClipboardHtml {
|
||||
/// Most rows Save as Web Page writes. A sheet whose used range is taller fails instead of losing cells.
|
||||
const HTML_EXPORT_MAX_ROWS: u32 = 100_001;
|
||||
|
||||
/// A sheet as a standalone HTML table (Save as Web Page).
|
||||
/// A sheet as a standalone HTML table (Save as Web Page). A sheet with pictures gets the sheet's
|
||||
/// own column widths and row heights so each picture and chart (inline SVG) can sit at its anchor.
|
||||
pub fn to_html(wb: &Workbook, sheet: usize) -> Result<String> {
|
||||
let Some(sh) = wb.sheet(sheet) else { return Ok(String::new()) };
|
||||
let fixed = !(sh.images.is_empty() && sh.charts.is_empty());
|
||||
let used = sh.used_range();
|
||||
let mut out = String::new();
|
||||
let _ = write!(
|
||||
out,
|
||||
"<!doctype html><html><head><meta charset=\"utf-8\"><title>{}</title><style>table{{border-collapse:collapse;font-family:Calibri,Carlito,Arial,sans-serif;font-size:11pt}}td{{border:1px solid #d4d4d4;padding:2px 4px;white-space:nowrap}}</style></head><body><table>",
|
||||
esc(&sh.name)
|
||||
"<!doctype html><html><head><meta charset=\"utf-8\"><title>{}</title><style>table{{border-collapse:collapse;font-family:Calibri,Carlito,Arial,sans-serif;font-size:11pt{}}}td{{border:1px solid #d4d4d4;padding:{};white-space:nowrap{}}}</style></head><body>",
|
||||
esc(&sh.name),
|
||||
if fixed { ";table-layout:fixed" } else { "" },
|
||||
if fixed { "0 4px" } else { "2px 4px" },
|
||||
if fixed { ";overflow:hidden;line-height:1" } else { "" }
|
||||
);
|
||||
if let Some(r) = sh.used_range() {
|
||||
if fixed {
|
||||
out.push_str("<div style=\"position:relative\">");
|
||||
}
|
||||
let _ = write!(out, "<table{}", table_dir(sh));
|
||||
if let (true, Some(r)) = (fixed, used) {
|
||||
let width: f32 = (r.start.col..=r.end.col).map(|c| sh.col_width(c)).sum();
|
||||
let _ = write!(out, " style=\"width:{width}px\"");
|
||||
}
|
||||
out.push('>');
|
||||
if let Some(r) = used {
|
||||
if r.height() > HTML_EXPORT_MAX_ROWS {
|
||||
return Err(EngineError::Other(format!(
|
||||
"the sheet is too tall for a web page ({} rows, at most {HTML_EXPORT_MAX_ROWS}). Save as .xlsx or CSV instead.",
|
||||
r.height()
|
||||
)));
|
||||
}
|
||||
if fixed {
|
||||
out.push_str("<colgroup>");
|
||||
for col in (r.start.col..=r.end.col).filter(|&c| !sh.is_col_hidden(c)) {
|
||||
let _ = write!(out, "<col style=\"width:{}px\">", sh.col_width(col));
|
||||
}
|
||||
out.push_str("</colgroup>");
|
||||
}
|
||||
for row in r.start.row..=r.end.row {
|
||||
if sh.is_row_hidden(row) {
|
||||
continue;
|
||||
}
|
||||
out.push_str("<tr>");
|
||||
if fixed {
|
||||
let _ = write!(out, "<tr style=\"height:{}px\">", sh.row_height(row));
|
||||
} else {
|
||||
out.push_str("<tr>");
|
||||
}
|
||||
for col in r.start.col..=r.end.col {
|
||||
let c = CellRef::new(row, col);
|
||||
if sh.merges.iter().any(|m| m.contains(c) && m.start != c) {
|
||||
if sh.merges.iter().any(|m| m.contains(c) && m.start != c) || (fixed && sh.is_col_hidden(col)) {
|
||||
continue;
|
||||
}
|
||||
let css = html_cell_css(wb, sh, c);
|
||||
let text = crate::display::cell_text(wb, sh, c);
|
||||
let dir = CellDir::of(sh, c, &text);
|
||||
let css = html_cell_css(wb, sh, c, dir);
|
||||
let span = sh
|
||||
.merges
|
||||
.iter()
|
||||
.find(|m| m.start == c)
|
||||
.map(|m| format!(" rowspan=\"{}\" colspan=\"{}\"", m.height(), m.width()))
|
||||
.map(|m| {
|
||||
if fixed {
|
||||
let rows = (m.start.row..=m.end.row).filter(|&y| !sh.is_row_hidden(y)).count();
|
||||
let cols = (m.start.col..=m.end.col).filter(|&x| !sh.is_col_hidden(x)).count();
|
||||
format!(" rowspan=\"{rows}\" colspan=\"{cols}\"")
|
||||
} else {
|
||||
format!(" rowspan=\"{}\" colspan=\"{}\"", m.height(), m.width())
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let _ = write!(out, "<td style=\"{css}\"{span}>{}</td>", esc(&crate::display::cell_text(wb, sh, c)));
|
||||
let _ = write!(out, "<td style=\"{css}\"{}{span}>{}</td>", dir.attr(), esc(&text));
|
||||
}
|
||||
out.push_str("</tr>");
|
||||
}
|
||||
}
|
||||
out.push_str("</table></body></html>");
|
||||
out.push_str("</table>");
|
||||
if fixed {
|
||||
// Sheet units are CSS pixels; the table starts at the used range, not at A1.
|
||||
let (left, top) = used.map_or((0.0, 0.0), |r| (sh.col_left(r.start.col), sh.row_top(r.start.row)));
|
||||
let place = |a: &gridcraft_model::Anchor| {
|
||||
format!(
|
||||
"position:absolute;left:{}px;top:{}px;width:{}px;height:{}px",
|
||||
sh.col_left(a.cell.col) - left + f64::from(a.dx),
|
||||
sh.row_top(a.cell.row) - top + f64::from(a.dy),
|
||||
a.width.max(0.0),
|
||||
a.height.max(0.0)
|
||||
)
|
||||
};
|
||||
for im in &sh.images {
|
||||
let _ = write!(
|
||||
out,
|
||||
"<img src=\"data:{};base64,{}\" alt=\"{}\" style=\"{}\">",
|
||||
esc(&im.mime),
|
||||
base64_encode(&im.data),
|
||||
esc(&im.alt),
|
||||
place(&im.anchor)
|
||||
);
|
||||
}
|
||||
for ch in &sh.charts {
|
||||
if let Some(svg) = crate::chart_svg::chart_svg(wb, sheet, ch, &place(&ch.anchor)) {
|
||||
out.push_str(&svg);
|
||||
}
|
||||
}
|
||||
out.push_str("</div>");
|
||||
}
|
||||
out.push_str("</body></html>");
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// How many [`ScriptSandbox`]es are alive on this thread.
|
||||
static SCRIPT_SANDBOX: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// While one is alive, [`read_file`] and [`write_file`] on this thread refuse every path: a
|
||||
/// stored script's steps run inside one (#2178), so no command reaches the file system from a
|
||||
/// script whatever its parameters are called. Dropping it (also on unwind) lifts the refusal.
|
||||
pub(crate) struct ScriptSandbox(());
|
||||
|
||||
impl ScriptSandbox {
|
||||
pub(crate) fn enter() -> Self {
|
||||
let _ = SCRIPT_SANDBOX.try_with(|c| c.set(c.get().saturating_add(1)));
|
||||
ScriptSandbox(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ScriptSandbox {
|
||||
fn drop(&mut self) {
|
||||
let _ = SCRIPT_SANDBOX.try_with(|c| c.set(c.get().saturating_sub(1)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Refuses file access while a [`ScriptSandbox`] is alive (and, failing closed, while the
|
||||
/// 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")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn read_file(path: &str) -> Result<Vec<u8>> {
|
||||
script_sandbox_check(path)?;
|
||||
let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
if meta.len() > 2 * 1024 * 1024 * 1024 {
|
||||
return Err(EngineError::Other("files larger than 2 GB aren't supported".into()));
|
||||
@@ -410,11 +600,13 @@ pub fn read_file(path: &str) -> Result<Vec<u8>> {
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn read_file(path: &str) -> Result<Vec<u8>> {
|
||||
script_sandbox_check(path)?;
|
||||
Err(EngineError::Other(format!("{path}: no file system in the browser")))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
script_sandbox_check(path)?;
|
||||
// Write to a temporary file, then rename: a crash never leaves a half-written workbook.
|
||||
let tmp = format!("{path}.sctmp");
|
||||
std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path)).map_err(|e| {
|
||||
@@ -427,6 +619,7 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn write_file(path: &str, _bytes: &[u8]) -> Result<()> {
|
||||
script_sandbox_check(path)?;
|
||||
Err(EngineError::Other(format!("{path}: no file system in the browser")))
|
||||
}
|
||||
|
||||
@@ -500,6 +693,190 @@ pub fn image_size(data: &[u8]) -> Option<(u32, u32)> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn book(rtl: bool, cells: &[(&str, gridcraft_core::Value, gridcraft_model::HAlign)]) -> Workbook {
|
||||
let mut wb = Workbook::new();
|
||||
let ids: Vec<_> = cells.iter().map(|(_, _, h)| wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |st| st.align.h = *h)).collect();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
sh.right_to_left = rtl;
|
||||
for ((at, value, _), id) in cells.iter().zip(ids) {
|
||||
let c = CellRef::parse(at).unwrap();
|
||||
sh.set_value(c, value.clone());
|
||||
sh.set_style(c, id);
|
||||
}
|
||||
wb
|
||||
}
|
||||
|
||||
fn copied(wb: &Workbook, range: &str) -> String {
|
||||
let r = RangeRef::parse(range).unwrap();
|
||||
range_html(wb, 0, r, &crate::display::range_text(wb, 0, r)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_export_embeds_images_as_data_uris() {
|
||||
let mut wb = Workbook::new();
|
||||
wb.sheet_mut(0).unwrap().images.push(gridcraft_model::Image {
|
||||
id: 1,
|
||||
anchor: gridcraft_model::Anchor { cell: CellRef::new(0, 0), dx: 0.0, dy: 0.0, width: 72.0, height: 36.0, mode: Default::default() },
|
||||
data: vec![1, 2, 3].into(),
|
||||
mime: "image/png".into(),
|
||||
alt: "a \"logo\"".into(),
|
||||
});
|
||||
let html = to_html(&wb, 0).unwrap();
|
||||
assert!(
|
||||
html.contains("<img src=\"data:image/png;base64,AQID\" alt=\"a "logo"\" style=\"position:absolute;left:0px;top:0px;width:72px;height:36px\"></div></body>"),
|
||||
"{html}"
|
||||
);
|
||||
assert!(html.contains("<div style=\"position:relative\"><table"), "{html}");
|
||||
assert!(!to_html(&Workbook::new(), 0).unwrap().contains("<img"), "no images, no <img>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_export_places_images_at_their_anchor_using_sheet_geometry() {
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
sh.cols.insert(1, gridcraft_model::LineInfo { size: Some(100.0), ..Default::default() });
|
||||
sh.rows.insert(1, gridcraft_model::LineInfo { size: Some(30.0), ..Default::default() });
|
||||
sh.images.push(gridcraft_model::Image {
|
||||
id: 1,
|
||||
anchor: gridcraft_model::Anchor { cell: CellRef::new(3, 2), dx: 5.0, dy: 7.0, width: 10.0, height: 20.0, mode: Default::default() },
|
||||
data: vec![1].into(),
|
||||
mime: "image/png".into(),
|
||||
alt: String::new(),
|
||||
});
|
||||
sh.set_value(CellRef::new(1, 1), gridcraft_core::Value::Number(1.0));
|
||||
let html = to_html(&wb, 0).unwrap();
|
||||
// The table starts at B2; the picture is at C4: one 100 px column and two rows (30 + 20) further on.
|
||||
assert!(html.contains("<col style=\"width:100px\">"), "{html}");
|
||||
assert!(html.contains("<tr style=\"height:30px\">"), "{html}");
|
||||
assert!(html.contains("left:105px;top:57px;width:10px;height:20px"), "{html}");
|
||||
}
|
||||
|
||||
fn book_with_picture(anchor: &str, cell_at: (u32, u32)) -> Workbook {
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
sh.set_value(CellRef::new(cell_at.0, cell_at.1), gridcraft_core::Value::Number(1.0));
|
||||
sh.images.push(gridcraft_model::Image {
|
||||
id: 1,
|
||||
anchor: gridcraft_model::Anchor {
|
||||
cell: CellRef::parse(anchor).unwrap(),
|
||||
dx: 0.0,
|
||||
dy: 0.0,
|
||||
width: 10.0,
|
||||
height: 10.0,
|
||||
mode: Default::default(),
|
||||
},
|
||||
data: vec![1].into(),
|
||||
mime: "image/png".into(),
|
||||
alt: String::new(),
|
||||
});
|
||||
wb
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_export_places_images_past_hidden_columns_and_rows() {
|
||||
let mut wb = book_with_picture("C3", (0, 0));
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
sh.set_value(CellRef::new(2, 2), gridcraft_core::Value::Number(2.0));
|
||||
sh.cols.insert(1, gridcraft_model::LineInfo { hidden: true, ..Default::default() });
|
||||
sh.rows.insert(1, gridcraft_model::LineInfo { hidden: true, ..Default::default() });
|
||||
let html = to_html(&wb, 0).unwrap();
|
||||
// Column B and row 2 take no room: C3 is one default column and row from A1.
|
||||
assert_eq!(html.matches("<col ").count(), 2, "{html}");
|
||||
assert_eq!(html.matches("<tr ").count(), 2, "{html}");
|
||||
assert!(html.contains("left:64px;top:20px"), "{html}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_export_places_images_above_and_left_of_the_table() {
|
||||
let html = to_html(&book_with_picture("A1", (2, 2)), 0).unwrap();
|
||||
// The table starts at C3, so a picture at A1 sits before it instead of failing.
|
||||
assert!(html.contains("left:-128px;top:-40px"), "{html}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_export_marks_right_to_left_sheets() {
|
||||
use gridcraft_core::Value;
|
||||
use gridcraft_model::HAlign;
|
||||
let wb = book(true, &[("A1", Value::text("Hello!"), HAlign::General), ("B1", Value::Number(-5.0), HAlign::General)]);
|
||||
let html = to_html(&wb, 0).unwrap();
|
||||
assert!(html.contains("<body><table dir=\"rtl\">"), "{html}");
|
||||
assert!(html.contains("<td style=\"\" dir=\"auto\">Hello!</td>"), "Latin text keeps its own direction: {html}");
|
||||
assert!(
|
||||
html.contains("<td style=\"text-align:left;\" dir=\"ltr\">-5</td>"),
|
||||
"numbers mirror to the left and keep their sign in front: {html}"
|
||||
);
|
||||
let clip = copied(&wb, "A1:B1");
|
||||
assert!(clip.starts_with("<table dir=\"rtl\" style="), "{clip}");
|
||||
assert_eq!(clip.matches("dir=\"auto\"").count(), 1, "{clip}");
|
||||
assert!(clip.contains("text-align:left;\" dir=\"ltr\">-5</td>"), "{clip}");
|
||||
|
||||
let ltr = book(false, &[("A1", Value::text("Hello!"), HAlign::General)]);
|
||||
assert!(to_html(<r, 0).unwrap().contains("<body><table><tr>"));
|
||||
assert!(copied(<r, "A1").starts_with("<table style="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_export_lets_arabic_general_text_align_by_direction() {
|
||||
use gridcraft_core::Value;
|
||||
use gridcraft_model::HAlign;
|
||||
let wb = book(
|
||||
false,
|
||||
&[
|
||||
("A1", Value::text("مرحبا!"), HAlign::General),
|
||||
("B1", Value::text("שלום"), HAlign::Left),
|
||||
("C1", Value::text("مرحبا"), HAlign::Center),
|
||||
("D1", Value::text("Hello"), HAlign::General),
|
||||
],
|
||||
);
|
||||
let html = to_html(&wb, 0).unwrap();
|
||||
assert!(html.contains("<td style=\"\" dir=\"auto\">مرحبا!</td>"), "General Arabic text aligns by its direction: {html}");
|
||||
assert!(html.contains("<td style=\"text-align:left;\" dir=\"auto\">שלום</td>"), "explicit alignment stays: {html}");
|
||||
assert!(html.contains("<td style=\"text-align:center;\" dir=\"auto\">مرحبا</td>"), "{html}");
|
||||
assert!(html.contains("<td style=\"text-align:left;\">Hello</td>"), "{html}");
|
||||
assert!(html.contains("<body><table><tr>"), "a left-to-right sheet has no table direction: {html}");
|
||||
|
||||
let clip = copied(&wb, "A1:D1");
|
||||
let cells: Vec<&str> = clip.split("<td ").skip(1).collect();
|
||||
assert_eq!(cells.len(), 4, "{clip}");
|
||||
assert!(cells[0].contains("dir=\"auto\"") && !cells[0].contains("text-align"), "{clip}");
|
||||
assert!(cells[1].contains("text-align:left;") && cells[1].contains("dir=\"auto\""), "{clip}");
|
||||
assert!(cells[3].contains("text-align:left;") && !cells[3].contains("dir="), "{clip}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_export_of_latin_ltr_sheet_is_unchanged() {
|
||||
use gridcraft_core::Value;
|
||||
use gridcraft_model::HAlign;
|
||||
let mut wb = book(
|
||||
false,
|
||||
&[
|
||||
("A1", Value::text("Name"), HAlign::General),
|
||||
("B1", Value::text("Qty"), HAlign::Center),
|
||||
("A2", Value::text("Apples & <pears>"), HAlign::General),
|
||||
("B2", Value::Number(3.0), HAlign::General),
|
||||
("A3", Value::text("Total"), HAlign::Right),
|
||||
],
|
||||
);
|
||||
wb.sheet_mut(0).unwrap().merges.push(RangeRef::parse("A3:B3").unwrap());
|
||||
assert_eq!(
|
||||
to_html(&wb, 0).unwrap(),
|
||||
"<!doctype html><html><head><meta charset=\"utf-8\"><title>Sheet1</title><style>table{border-collapse:collapse;font-family:Calibri,Carlito,Arial,sans-serif;font-size:11pt}td{border:1px solid #d4d4d4;padding:2px 4px;white-space:nowrap}</style></head><body><table>\
|
||||
<tr><td style=\"text-align:left;\">Name</td><td style=\"text-align:center;\">Qty</td></tr>\
|
||||
<tr><td style=\"text-align:left;\">Apples & <pears></td><td style=\"text-align:right;\">3</td></tr>\
|
||||
<tr><td style=\"text-align:right;\" rowspan=\"1\" colspan=\"2\">Total</td></tr>\
|
||||
</table></body></html>"
|
||||
);
|
||||
let cell = "border:1px solid #d4d4d4;padding:2px 4px;white-space:pre-wrap;";
|
||||
assert_eq!(
|
||||
copied(&wb, "A1:B2"),
|
||||
format!(
|
||||
"<table style=\"border-collapse:collapse;font-family:Calibri,Carlito,Arial,sans-serif;font-size:11pt\">\
|
||||
<tr><td style=\"{cell}text-align:left;\">Name</td><td style=\"{cell}text-align:center;\">Qty</td></tr>\
|
||||
<tr><td style=\"{cell}text-align:left;\">Apples & <pears></td><td style=\"{cell}text-align:right;\">3</td></tr></table>"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_export_fails_instead_of_dropping_cells_past_the_row_limit() {
|
||||
let mut wb = Workbook::new();
|
||||
@@ -517,7 +894,7 @@ mod tests {
|
||||
#[test]
|
||||
fn macros_are_saved_only_into_xlsm() {
|
||||
let mut wb = Workbook::new();
|
||||
wb.vba_project = Some(b"vba".to_vec());
|
||||
wb.vba_project = Some(b"vba".to_vec().into());
|
||||
let has_macros = |path: &str| {
|
||||
let bytes = save_bytes(&wb, path).unwrap();
|
||||
gridcraft_xlsx::read_xlsx(&bytes).unwrap().0.vba_project.is_some()
|
||||
@@ -561,6 +938,32 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// While a script runs (#2178), file reads and writes are refused whatever command or
|
||||
/// parameter asked for them; nesting and dropping the sandbox restore access.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn script_sandbox_refuses_file_access_until_dropped() {
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-sandbox-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let existing = dir.join("in.csv");
|
||||
std::fs::write(&existing, b"a,b\n").unwrap();
|
||||
let (existing, out) = (existing.to_string_lossy().to_string(), dir.join("out.xlsx").to_string_lossy().to_string());
|
||||
{
|
||||
let _outer = ScriptSandbox::enter();
|
||||
{
|
||||
let _inner = ScriptSandbox::enter();
|
||||
assert!(read_file(&existing).is_err());
|
||||
}
|
||||
assert!(read_file(&existing).is_err(), "the outer sandbox still holds");
|
||||
assert!(write_file(&out, b"leak").is_err());
|
||||
assert!(!std::path::Path::new(&out).exists());
|
||||
}
|
||||
assert_eq!(read_file(&existing).unwrap(), b"a,b\n");
|
||||
write_file(&out, b"ok").unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A minimal compound (CFB/OLE2) file — FAT in sector 0, directory in sector 1 — holding a
|
||||
/// root entry and the given stream names.
|
||||
fn cfb(streams: &[&str]) -> Vec<u8> {
|
||||
@@ -725,6 +1128,54 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Where `part`'s entry starts in the central directory of `package`.
|
||||
fn central_entry(package: &[u8], part: &str) -> usize {
|
||||
let mut at = 0;
|
||||
while let Some(i) = package.get(at..).and_then(|rest| rest.windows(4).position(|w| w == b"PK\x01\x02")) {
|
||||
let entry = at + i;
|
||||
let len = u16::from_le_bytes([package[entry + 28], package[entry + 29]]) as usize;
|
||||
if &package[entry + 46..entry + 46 + len] == part.as_bytes() {
|
||||
return entry;
|
||||
}
|
||||
at = entry + 46;
|
||||
}
|
||||
panic!("{part} is not in the package");
|
||||
}
|
||||
|
||||
/// `package` with `part` declaring `size` unpacked bytes: the reader refuses the part on the
|
||||
/// declared size, so no real 512 MB part has to be built.
|
||||
fn declaring(mut package: Vec<u8>, part: &str, size: u32) -> Vec<u8> {
|
||||
let at = central_entry(&package, part);
|
||||
package[at + 24..at + 28].copy_from_slice(&size.to_le_bytes());
|
||||
package
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_part_over_the_size_limit_says_how_to_get_the_data_in() {
|
||||
let mut wb = Workbook::new();
|
||||
wb.sheet_mut(0).unwrap().set_value(CellRef::new(0, 0), gridcraft_core::Value::text("big"));
|
||||
let package = save_bytes(&wb, "big.xlsx").unwrap();
|
||||
// The size reported in storytold/gridcraft#1242.
|
||||
let msg = open_bytes("big.xlsx", &declaring(package.clone(), "xl/worksheets/sheet1.xml", 540_278_731)).unwrap_err().to_string();
|
||||
assert!(msg.starts_with("We can't open 'big.xlsx': "), "{msg}");
|
||||
assert!(msg.contains("xl/worksheets/sheet1.xml") && msg.contains("515 MB") && msg.contains("512 MB"), "{msg}");
|
||||
assert!(msg.contains("several sheets") && msg.contains("CSV"), "{msg}");
|
||||
assert!(!msg.contains("bytes"), "{msg}");
|
||||
// Splitting sheets doesn't shrink the shared strings, which every sheet uses: only CSV helps.
|
||||
let msg = open_bytes("big.xlsx", &declaring(package.clone(), "xl/sharedStrings.xml", 540_278_731)).unwrap_err().to_string();
|
||||
assert!(msg.contains("xl/sharedStrings.xml") && msg.contains("515 MB") && msg.contains("CSV"), "{msg}");
|
||||
assert!(!msg.contains("several sheets"), "{msg}");
|
||||
// Just over the limit never reads as a size equal to it.
|
||||
let msg = open_bytes("big.xlsx", &declaring(package, "xl/worksheets/sheet1.xml", 512 * 1024 * 1024 + 1)).unwrap_err().to_string();
|
||||
assert!(msg.contains("more than 512 MB"), "{msg}");
|
||||
// A package that fails for another reason keeps its own error, oversized part or not.
|
||||
let mut broken = declaring(save_bytes(&wb, "big.xlsx").unwrap(), "xl/worksheets/sheet1.xml", 540_278_731);
|
||||
let at = central_entry(&broken, "xl/workbook.xml");
|
||||
broken[at + 46 + "xl/workbook.xml".len() - 1] = b'x';
|
||||
let msg = open_bytes("big.xlsx", &broken).unwrap_err().to_string();
|
||||
assert!(msg.starts_with("We can't open 'big.xlsx': ") && !msg.contains("unpacks"), "{msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_wins_over_a_mismatched_extension() {
|
||||
let mut wb = Workbook::new();
|
||||
|
||||
+101
-8
@@ -8,6 +8,7 @@
|
||||
|
||||
pub mod catalog;
|
||||
pub mod cf;
|
||||
mod chart_svg;
|
||||
mod clip_html;
|
||||
pub mod cmd;
|
||||
pub mod display;
|
||||
@@ -61,6 +62,8 @@ pub struct HistoryEntry {
|
||||
pub label: String,
|
||||
pub wb: Arc<Workbook>,
|
||||
pub selection: Selection,
|
||||
/// Estimate of storage this snapshot retains beyond the next workbook version.
|
||||
pub retained_bytes: usize,
|
||||
}
|
||||
|
||||
/// The clipboard: a block of cells (with formulas and styles) or marching-ants reference.
|
||||
@@ -112,15 +115,65 @@ pub struct DocState {
|
||||
/// The format `path`'s content was recognised as when its extension doesn't name one (an
|
||||
/// XLSX called `budget.dat`): Save writes it back in that format.
|
||||
pub content_kind: Option<io::FileKind>,
|
||||
/// Whether the scripts stored in this workbook may run (`automate.runScript`). A workbook
|
||||
/// that arrives already carrying scripts (opened from a file) starts untrusted until the
|
||||
/// user trusts it in this session (`automate.trustScripts`); one with no scripts starts
|
||||
/// trusted, since any script it gains later was written here.
|
||||
pub scripts_trusted: bool,
|
||||
}
|
||||
|
||||
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
const UNDO_LIMIT: usize = 100;
|
||||
/// Past this many entries the journal drops its oldest half.
|
||||
const JOURNAL_MAX_ENTRIES: usize = 10_000;
|
||||
/// Past this many bytes (entries serialized as JSON) the journal drops its oldest entries until
|
||||
/// it is under half. Entries keep their full params so they replay (recorded scripts,
|
||||
/// `engine.journal`), and a `file.open` holds the whole file as base64: without a size cap a
|
||||
/// few opens of a large workbook keep tens of MB alive.
|
||||
pub(crate) const JOURNAL_MAX_BYTES: usize = 64 * 1024 * 1024;
|
||||
const UNDO_BYTES_LIMIT: usize = 128 * 1024 * 1024;
|
||||
|
||||
fn history_retained_bytes(older: &Workbook, newer: &Workbook) -> usize {
|
||||
let mut bytes = std::mem::size_of::<Workbook>();
|
||||
for (index, sheet) in older.sheets.iter().enumerate() {
|
||||
// Moving or inserting sheets must not charge unchanged sheets at a new index.
|
||||
if newer.sheets.iter().any(|next| Arc::ptr_eq(sheet, next)) {
|
||||
continue;
|
||||
}
|
||||
bytes = bytes.saturating_add(std::mem::size_of::<gridcraft_model::Sheet>());
|
||||
let empty = gridcraft_model::CellStore::new();
|
||||
let next = newer.sheets.get(index).map_or(&empty, |next| &next.cells);
|
||||
bytes = bytes.saturating_add(sheet.cells.retained_bytes_against(next));
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
fn push_history(history: &mut Vec<HistoryEntry>, label: String, wb: Arc<Workbook>, next: &Workbook, selection: Selection) {
|
||||
push_history_with_budget(history, label, wb, next, selection, UNDO_BYTES_LIMIT);
|
||||
}
|
||||
|
||||
fn push_history_with_budget(
|
||||
history: &mut Vec<HistoryEntry>,
|
||||
label: String,
|
||||
wb: Arc<Workbook>,
|
||||
next: &Workbook,
|
||||
selection: Selection,
|
||||
byte_budget: usize,
|
||||
) {
|
||||
let retained_bytes = history_retained_bytes(&wb, next);
|
||||
history.push(HistoryEntry { label, wb, selection, retained_bytes });
|
||||
let mut total = history.iter().fold(0usize, |sum, entry| sum.saturating_add(entry.retained_bytes));
|
||||
// Always preserve the latest action, even if that one edit exceeds the budget.
|
||||
while history.len() > 1 && (history.len() > UNDO_LIMIT || total > byte_budget) {
|
||||
total = total.saturating_sub(history.remove(0).retained_bytes);
|
||||
}
|
||||
}
|
||||
|
||||
impl DocState {
|
||||
pub fn new(mut wb: Workbook, path: Option<String>, title: String) -> DocState {
|
||||
let mut calc = Calc::new();
|
||||
calc.recalc_all(&mut wb);
|
||||
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());
|
||||
DocState {
|
||||
@@ -137,6 +190,7 @@ impl DocState {
|
||||
sheet_selections: vec![],
|
||||
unlocked_ranges: vec![],
|
||||
content_kind: None,
|
||||
scripts_trusted,
|
||||
}
|
||||
}
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
@@ -227,6 +281,13 @@ pub enum UiRequest {
|
||||
OpenUrl(String),
|
||||
}
|
||||
|
||||
/// Width in points of one line of text drawn in a cell font, measured by a front end that has
|
||||
/// the font the grid draws with. With `exact` false the front end may answer with a cheaper
|
||||
/// measure that is never narrower (AutoFit asks for it on columns with many distinct values).
|
||||
/// Commands call it while they run, so a front end must not run commands while holding a lock
|
||||
/// the measure takes (egui's: see `theme::cell_text_width` in the UI crate).
|
||||
pub type TextWidth = Arc<dyn Fn(&str, &model::Font, bool) -> f32 + Send + Sync>;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Session {
|
||||
docs: Vec<DocState>,
|
||||
@@ -236,6 +297,9 @@ pub struct Session {
|
||||
pub mode: Mode,
|
||||
/// Commands run so far, without their passwords ([`cmd::journal_params`]).
|
||||
pub journal: Vec<(String, Json)>,
|
||||
/// Serialized size of each journal entry, measured once when it is pushed, and their sum.
|
||||
journal_sizes: Vec<usize>,
|
||||
journal_bytes: usize,
|
||||
pub ui_requests: Vec<UiRequest>,
|
||||
/// Format painter source style (sticky when double-clicked).
|
||||
pub format_painter: Option<(Arc<Workbook>, usize, RangeRef, bool)>,
|
||||
@@ -256,6 +320,9 @@ pub struct Session {
|
||||
/// Review › Notes › Show All Notes: every note is displayed. An application display setting
|
||||
/// (like Excel's comment indicator option), so it's not saved in the file.
|
||||
pub show_all_notes: bool,
|
||||
/// Measures cell text for AutoFit Column Width. Without it (CLI, MCP) AutoFit uses
|
||||
/// [`cmd::format::approx_text_width`].
|
||||
pub text_width: Option<TextWidth>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -309,6 +376,38 @@ impl Session {
|
||||
self.add_document(DocState::new(wb, None, format!("Book{n}")))
|
||||
}
|
||||
|
||||
/// Journals a command, then trims the oldest entries past either cap. A recording in progress
|
||||
/// keeps pointing at the same entry (the steps the trim dropped are lost).
|
||||
fn push_journal(&mut self, id: &str, params: Json) {
|
||||
let size = |e: &(String, Json)| serde_json::to_vec(e).map_or(0, |v| v.len());
|
||||
// `journal` is public: if it was changed from outside, measure it again.
|
||||
if self.journal_sizes.len() != self.journal.len() {
|
||||
self.journal_sizes = self.journal.iter().map(size).collect();
|
||||
self.journal_bytes = self.journal_sizes.iter().sum();
|
||||
}
|
||||
let entry = (id.to_string(), params);
|
||||
let entry_size = size(&entry);
|
||||
self.journal.push(entry);
|
||||
self.journal_sizes.push(entry_size);
|
||||
self.journal_bytes = self.journal_bytes.saturating_add(entry_size);
|
||||
let len = self.journal.len();
|
||||
let mut n = if len > JOURNAL_MAX_ENTRIES { len - JOURNAL_MAX_ENTRIES / 2 } else { 0 };
|
||||
let mut kept = self.journal_bytes.saturating_sub(self.journal_sizes.iter().take(n).sum());
|
||||
if self.journal_bytes > JOURNAL_MAX_BYTES {
|
||||
// The newest entry stays even when it is larger than the cap on its own.
|
||||
while kept > JOURNAL_MAX_BYTES / 2 && n + 1 < len {
|
||||
kept = kept.saturating_sub(self.journal_sizes.get(n).copied().unwrap_or(0));
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
if n > 0 {
|
||||
self.journal.drain(..n);
|
||||
self.journal_sizes.drain(..n);
|
||||
self.journal_bytes = kept;
|
||||
self.recording = self.recording.map(|r| r.saturating_sub(n));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn commands(&self) -> Vec<CommandInfo> {
|
||||
command_specs().iter().map(|c| c.info(self)).collect()
|
||||
}
|
||||
@@ -340,20 +439,14 @@ impl Session {
|
||||
}
|
||||
};
|
||||
if spec.journal && result.is_ok() {
|
||||
self.journal.push((id.to_string(), cmd::journal_params(¶ms)));
|
||||
if self.journal.len() > 10_000 {
|
||||
self.journal.drain(..5_000);
|
||||
}
|
||||
self.push_journal(id, cmd::journal_params(¶ms));
|
||||
}
|
||||
if spec.undoable
|
||||
&& let Some((uid, wb, sel)) = before
|
||||
&& let Some(d) = self.docs.iter_mut().find(|d| d.uid == uid)
|
||||
&& !Arc::ptr_eq(&d.wb, &wb)
|
||||
{
|
||||
d.undo.push(HistoryEntry { label: spec.label.trim_end_matches('…').to_string(), wb, selection: sel });
|
||||
if d.undo.len() > UNDO_LIMIT {
|
||||
d.undo.remove(0);
|
||||
}
|
||||
push_history(&mut d.undo, spec.label.trim_end_matches('…').to_string(), wb, &d.wb, sel);
|
||||
d.redo.clear();
|
||||
d.revision += 1;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ use std::cmp::Ordering;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use gridcraft_core::date::datetime_from_serial;
|
||||
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value};
|
||||
pub use gridcraft_core::quote_sheet;
|
||||
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, split_sheet};
|
||||
use gridcraft_model::*;
|
||||
|
||||
use crate::pivot_calc::CalcSet;
|
||||
@@ -47,26 +48,6 @@ impl Source {
|
||||
}
|
||||
}
|
||||
|
||||
/// `'My Sheet'!A1` → (`Some("My Sheet")`, `A1`).
|
||||
fn split_sheet(r: &str) -> (Option<String>, &str) {
|
||||
match r.rsplit_once('!') {
|
||||
Some((sh, body)) => (Some(sh.trim_matches('\'').replace("''", "'")), body),
|
||||
None => (None, r),
|
||||
}
|
||||
}
|
||||
|
||||
/// Quotes a sheet name for a reference when needed.
|
||||
pub fn quote_sheet(name: &str) -> String {
|
||||
if !name.is_empty()
|
||||
&& name.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.')
|
||||
&& !name.chars().next().is_some_and(|c| c.is_ascii_digit())
|
||||
{
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("'{}'", name.replace('\'', "''"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Absolute reference text for a range on a sheet: `Sheet1!$A$1:$E$20`.
|
||||
pub fn range_text(sheet_name: &str, r: RangeRef) -> String {
|
||||
let abs = |c: CellRef| format!("${}${}", gridcraft_core::col_to_letters(c.col), c.row as u64 + 1);
|
||||
@@ -1511,7 +1492,7 @@ pub fn parse_chart_link(link: &str) -> Option<(String, String)> {
|
||||
};
|
||||
let (sheet, name) = t.rsplit_once('!')?;
|
||||
let sheet = sheet.trim();
|
||||
let sheet = sheet.strip_prefix('\'').and_then(|x| x.strip_suffix('\'')).map(|x| x.replace("''", "'")).unwrap_or_else(|| sheet.to_string());
|
||||
let sheet = gridcraft_core::unquote_sheet(sheet);
|
||||
let name = name.trim();
|
||||
if sheet.is_empty() || name.is_empty() {
|
||||
return None;
|
||||
@@ -1567,6 +1548,7 @@ pub fn chart_series(sheet: &str, origin: CellRef, out: &Output) -> Vec<Series> {
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
})
|
||||
|
||||
@@ -108,6 +108,7 @@ fn sales(s: &mut Session) -> bool {
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
quartiles: Default::default(),
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
}];
|
||||
|
||||
@@ -63,9 +63,13 @@ impl Selection {
|
||||
pub fn a1(&self) -> String {
|
||||
self.ranges.iter().map(|r| r.a1()).collect::<Vec<_>>().join(",")
|
||||
}
|
||||
/// Expands areas so merged cells are included whole.
|
||||
/// Expands areas so merged cells are included whole. Whole columns and whole rows stay as
|
||||
/// they are: clicking column B's header selects just B even when B1:C1 is merged, as in Excel.
|
||||
pub fn expand_merges(&mut self, sheet: &Sheet) {
|
||||
for r in self.ranges.iter_mut() {
|
||||
if r.is_full_cols() || r.is_full_rows() {
|
||||
continue;
|
||||
}
|
||||
let mut cur = *r;
|
||||
loop {
|
||||
let mut grown = cur;
|
||||
|
||||
+1318
-1
File diff suppressed because it is too large
Load Diff
+23
@@ -100,3 +100,26 @@ fn autofit_filter_width_is_undoable_without_changing_selection() {
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().col_width(0), 80.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autofit_measures_each_distinct_text_once_and_quickly_past_the_limit() {
|
||||
use std::sync::{Arc, Mutex};
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
// 2,500 distinct texts, each in two cells.
|
||||
let values: Vec<_> = (0..5000).map(|i| json!([format!("v{}", i % 2500)])).collect();
|
||||
s.execute("range.setValues", json!({"range":"A1", "values": values})).unwrap();
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
let seen = calls.clone();
|
||||
s.text_width = Some(Arc::new(move |text: &str, _: &gridcraft_engine::model::Font, exact: bool| {
|
||||
seen.lock().unwrap().push((text.to_owned(), exact));
|
||||
if text == "v2499" { 300.0 } else { 10.0 }
|
||||
}));
|
||||
s.execute("home.autofitColumnWidth", json!({"cols":"A:A"})).unwrap();
|
||||
let calls = calls.lock().unwrap();
|
||||
assert_eq!(calls.len(), 2500, "one measure per distinct text");
|
||||
assert_eq!(calls.iter().filter(|(_, exact)| *exact).count(), 2000);
|
||||
assert!(calls.iter().skip(2000).all(|(_, exact)| !exact));
|
||||
// The quick measures still size the column.
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().col_width(0), 306.0);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//! #1999: a conditional-format rule whose formula doesn't parse must be rejected at
|
||||
//! creation (Excel's object model refuses it too), not saved and poison the file.
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_cf_formulas_are_rejected() {
|
||||
for rule in [
|
||||
// exact repros from the issue's measured table
|
||||
json!({"type": "expression", "formula": "=A1>", "preset": "lightRedFill"}),
|
||||
json!({"type": "cellIs", "operator": "greaterThan", "value": "=1+", "preset": "lightRedFill"}),
|
||||
json!({"type": "dataBar", "min": {"type": "formula", "value": "1+"}}),
|
||||
// more garbage that the parser rejects
|
||||
json!({"type": "expression", "formula": "=IF(A1", "preset": "lightRedFill"}),
|
||||
json!({"type": "cellIs", "operator": "equal", "value": "=((A1", "preset": "lightRedFill"}),
|
||||
// multiple leading '=' on a stored-as-typed threshold: the writer strips one,
|
||||
// the rest must still parse (#1999 review)
|
||||
json!({"type": "dataBar", "min": {"type": "formula", "value": "===A1"}}),
|
||||
json!({"type": "dataBar", "max": {"type": "formula", "value": "==1+"}}),
|
||||
] {
|
||||
let err = session()
|
||||
.execute("home.conditionalFormat", json!({"range": "A1:A5", "rule": rule.clone()}))
|
||||
.expect_err("invalid formula {rule} must be rejected");
|
||||
let msg = err.to_string();
|
||||
assert!(msg.to_lowercase().contains("formula"), "error should mention the formula: {msg}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_cf_formulas_still_work() {
|
||||
let mut s = session();
|
||||
// bare comparison without '=' (the issue: this one saves fine today)
|
||||
s.execute("home.conditionalFormat", json!({"range": "A1:A5", "rule": {"type": "expression", "formula": "A1>5", "preset": "lightRedFill"}}))
|
||||
.unwrap();
|
||||
// leading '=' is accepted and stripped
|
||||
s.execute("home.conditionalFormat", json!({"range": "B1:B5", "rule": {"type": "expression", "formula": "=B1>5", "preset": "lightRedFill"}}))
|
||||
.unwrap();
|
||||
// cellIs numeric and text values are not formulas, must pass untouched
|
||||
s.execute(
|
||||
"home.conditionalFormat",
|
||||
json!({"range": "C1:C5", "rule": {"type": "cellIs", "operator": "greaterThan", "value": "5", "preset": "lightRedFill"}}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute(
|
||||
"home.conditionalFormat",
|
||||
json!({"range": "D1:D5", "rule": {"type": "cellIs", "operator": "equal", "value": "hello there", "preset": "lightRedFill"}}),
|
||||
)
|
||||
.unwrap();
|
||||
// dataBar formula thresholds that parse
|
||||
s.execute("home.conditionalFormat", json!({"range": "E1:E5", "rule": {"type": "dataBar", "min": {"type": "formula", "value": "MIN(E1:E5)"}, "max": {"type": "formula", "value": "MAX(E1:E5)"}}}))
|
||||
.unwrap();
|
||||
// non-formula rule types are untouched
|
||||
s.execute("home.conditionalFormat", json!({"range": "F1:F5", "rule": {"type": "duplicate", "preset": "lightRedFill"}})).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Chart series that point at workbook-scoped defined names. Excel writes these as `[0]!Name`
|
||||
//! in `<c:f>`; they used to read back verbatim and evaluate to `#NAME?`, so a chart
|
||||
//! driven by dynamic (OFFSET) names opened blank with a single `#NAME?` category.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
|
||||
use gridcraft_engine::{Session, io};
|
||||
use gridcraft_model::Workbook;
|
||||
use serde_json::json;
|
||||
|
||||
const TASKS: usize = 6;
|
||||
|
||||
/// A plan table, OFFSET names over its structured references (and names built on names; one
|
||||
/// scoped to the sheet), and a stacked bar chart whose series name those names.
|
||||
fn plan() -> Workbook {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let mut rows = vec![json!(["Task", "Start", "Done", "Left"])];
|
||||
for i in 0..TASKS {
|
||||
rows.push(json!([format!("Task {}", i + 1), (45_200 + 7 * i).to_string(), (i + 1).to_string(), (TASKS - i).to_string()]));
|
||||
}
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
|
||||
let range = format!("A1:D{}", TASKS + 1);
|
||||
s.execute("insert.table", json!({"range": range, "header": true, "name": "Plan"})).unwrap();
|
||||
for (name, refers, scope) in [
|
||||
("TaskNames", "=OFFSET(Plan[[#Headers],[Task]],1,0,COUNTA(Plan[Task]),1)", "Workbook"),
|
||||
("TaskStart", "=OFFSET(TaskNames,0,1)", "Workbook"),
|
||||
("TaskDone", "=OFFSET(TaskNames,0,2)", "Workbook"),
|
||||
("TaskLeft", "=OFFSET(TaskNames,0,3)", "Sheet1"),
|
||||
("LeftTitle", "=Sheet1!$D$1", "Workbook"),
|
||||
("DoneTitle", "=Sheet1!$C$1", "Sheet1"),
|
||||
] {
|
||||
s.execute("formulas.defineName", json!({"name": name, "refersTo": refers, "scope": scope})).unwrap();
|
||||
}
|
||||
s.execute("insert.chart", json!({"range": range, "type": "bar", "subtype": "stacked"})).unwrap();
|
||||
let mut wb = Workbook::clone(&s.doc().unwrap().wb);
|
||||
let chart = &mut wb.sheet_mut(0).unwrap().charts[0];
|
||||
assert_eq!(chart.series.len(), 3);
|
||||
for (ser, (name, values)) in
|
||||
chart.series.iter_mut().zip([("Sheet1!$B$1", "TaskStart"), ("Sheet1!DoneTitle", "TaskDone"), ("LeftTitle", "TaskLeft")])
|
||||
{
|
||||
ser.name = Some(name.into());
|
||||
ser.categories = Some("TaskNames".into());
|
||||
ser.values = values.into();
|
||||
}
|
||||
wb
|
||||
}
|
||||
|
||||
fn chart_xml(bytes: &[u8]) -> String {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut f = z.by_name("xl/charts/chart1.xml").unwrap();
|
||||
let mut xml = String::new();
|
||||
f.read_to_string(&mut xml).unwrap();
|
||||
xml
|
||||
}
|
||||
|
||||
/// The package with `edit` applied to its chart part.
|
||||
fn edit_chart(bytes: &[u8], edit: impl Fn(String) -> String) -> Vec<u8> {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).unwrap();
|
||||
let name = f.name().to_string();
|
||||
let mut data = Vec::new();
|
||||
f.read_to_end(&mut data).unwrap();
|
||||
if name == "xl/charts/chart1.xml" {
|
||||
data = edit(String::from_utf8(data).unwrap()).into_bytes();
|
||||
}
|
||||
out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
out.write_all(&data).unwrap();
|
||||
}
|
||||
out.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn reopen(bytes: &[u8]) -> Workbook {
|
||||
io::open_bytes("plan.xlsx", bytes).unwrap().0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workbook_names_in_chart_series_resolve_after_save_and_reopen() {
|
||||
let bytes = io::save_bytes(&plan(), "plan.xlsx").unwrap();
|
||||
// Qualified, as Excel needs them: it draws a chart with a bare name in `<c:f>` blank.
|
||||
let xml = chart_xml(&bytes);
|
||||
for f in ["[0]!TaskNames", "[0]!TaskStart", "[0]!TaskDone", "Sheet1!TaskLeft", "[0]!LeftTitle"] {
|
||||
assert!(xml.contains(&format!("<c:f>{f}</c:f>")), "{f} missing from {xml}");
|
||||
}
|
||||
assert!(!xml.contains("<c:f>Task"), "a bare name in {xml}");
|
||||
// Series names that are defined names stay references, qualified or not.
|
||||
assert!(xml.contains("<c:tx><c:strRef><c:f>Sheet1!DoneTitle</c:f>"), "{xml}");
|
||||
// A series name that is a defined name stays a reference, not literal text.
|
||||
assert!(!xml.contains("<c:v>LeftTitle</c:v>"));
|
||||
|
||||
let wb = reopen(&bytes);
|
||||
let chart = &wb.sheet(0).unwrap().charts[0];
|
||||
assert_eq!(chart.series[0].categories.as_deref(), Some("TaskNames"));
|
||||
assert_eq!(chart.series[0].values, "TaskStart");
|
||||
assert_eq!(chart.series[2].values, "Sheet1!TaskLeft");
|
||||
assert_eq!(chart.series[2].name.as_deref(), Some("LeftTitle"));
|
||||
|
||||
let data = gridcraft_chart::resolve(&wb, 0, chart);
|
||||
let tasks: Vec<String> = (1..=TASKS).map(|i| format!("Task {i}")).collect();
|
||||
assert_eq!(data.categories, tasks);
|
||||
let names: Vec<&str> = data.series.iter().map(|s| s.name.as_str()).collect();
|
||||
assert_eq!(names, ["Start", "Done", "Left"]);
|
||||
let start: Vec<Option<f64>> = (0..TASKS).map(|i| Some((45_200 + 7 * i) as f64)).collect();
|
||||
assert_eq!(data.series[0].values, start);
|
||||
let left: Vec<Option<f64>> = (0..TASKS).map(|i| Some((TASKS - i) as f64)).collect();
|
||||
assert_eq!(data.series[2].values, left);
|
||||
|
||||
// A second save and reopen gives the same chart data.
|
||||
let again = reopen(&io::save_bytes(&wb, "plan.xlsx").unwrap());
|
||||
assert_eq!(gridcraft_chart::resolve(&again, 0, &again.sheet(0).unwrap().charts[0]), data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_workbook_name_resolves_to_no_values_without_panicking() {
|
||||
let bytes = io::save_bytes(&plan(), "plan.xlsx").unwrap();
|
||||
let bytes = edit_chart(&bytes, |x| x.replace("Sheet1!TaskLeft", "[0]!NoSuchName"));
|
||||
let wb = reopen(&bytes);
|
||||
let chart = &wb.sheet(0).unwrap().charts[0];
|
||||
assert_eq!(chart.series[2].values, "NoSuchName");
|
||||
let data = gridcraft_chart::resolve(&wb, 0, chart);
|
||||
assert!(data.series[2].values.iter().all(Option::is_none), "{:?}", data.series[2].values);
|
||||
// The other series still plot.
|
||||
assert_eq!(data.series[0].values.len(), TASKS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_workbook_refs_in_chart_series_are_kept_as_they_were() {
|
||||
let bytes = io::save_bytes(&plan(), "plan.xlsx").unwrap();
|
||||
let bytes = edit_chart(&bytes, |x| x.replace("[0]!TaskDone", "[1]Sheet1!$C$2:$C$7"));
|
||||
let wb = reopen(&bytes);
|
||||
assert_eq!(wb.sheet(0).unwrap().charts[0].series[1].values, "[1]Sheet1!$C$2:$C$7");
|
||||
}
|
||||
+2
-2
@@ -23,7 +23,7 @@
|
||||
//!
|
||||
//! Knobs (environment variables):
|
||||
//! - `GRIDCRAFT_FUZZ_SEEDS` (default 6) and `GRIDCRAFT_FUZZ_STEPS` (default 40): the budget. Soak with
|
||||
//! `GRIDCRAFT_FUZZ_SEEDS=200 GRIDCRAFT_FUZZ_STEPS=200 cargo test --release -p gridcraft-engine --test command_sequence_fuzz`.
|
||||
//! `GRIDCRAFT_FUZZ_SEEDS=200 GRIDCRAFT_FUZZ_STEPS=200 cargo test --release -p gridcraft-engine --test it command_sequence_fuzz::`.
|
||||
//! - `GRIDCRAFT_FUZZ_SEED=17`: replay that one seed (the failure message prints the command line).
|
||||
//! - `GRIDCRAFT_FUZZ_SHRINK=1`: on failure, shrink the sequence to a minimal list of commands that
|
||||
//! still fails the same way, printed as `execute` calls ready for a regression test.
|
||||
@@ -138,7 +138,7 @@ fn report(seed: u64, steps: usize, sample: &str, sequence: &[Step], f: &Failure)
|
||||
None => format!("end-of-run checks after {} steps", sequence.len()),
|
||||
};
|
||||
let mut out = format!(
|
||||
"command-sequence fuzz failure: seed {seed} (sample `{sample}`), {at}\n invariant [{}]: {}\n replay: GRIDCRAFT_FUZZ_SEED={seed} GRIDCRAFT_FUZZ_STEPS={steps} cargo test -p gridcraft-engine --test command_sequence_fuzz\n",
|
||||
"command-sequence fuzz failure: seed {seed} (sample `{sample}`), {at}\n invariant [{}]: {}\n replay: GRIDCRAFT_FUZZ_SEED={seed} GRIDCRAFT_FUZZ_STEPS={steps} cargo test -p gridcraft-engine --test it command_sequence_fuzz::\n",
|
||||
f.kind, f.what
|
||||
);
|
||||
let end = (f.step + 1).min(sequence.len());
|
||||
@@ -0,0 +1,20 @@
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn visible_rows(op: &str) -> Vec<u32> {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range":"A1", "values":[["n"], ["3"], ["10"], [""], ["abc1"], ["20"]]})).unwrap();
|
||||
s.execute("selection.set", json!({"range":"A1:A6"})).unwrap();
|
||||
s.execute("data.filter", json!({})).unwrap();
|
||||
s.execute("data.filterBy", json!({"column":0, "custom":{"op":op, "value":"5"}})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let sh = doc.wb.active().unwrap();
|
||||
(1..6u32).filter(|r| !sh.rows.get(r).is_some_and(|i| i.hidden)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn less_than_numeric_criterion_hides_blank_and_text_rows() {
|
||||
assert_eq!(visible_rows("<"), vec![1]);
|
||||
assert_eq!(visible_rows("<="), vec![1]);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! A formula opened with dynamic-array metadata keeps it through edits that only move or adjust
|
||||
//! it, and loses it when it is retyped (the writer then decides from the formula's shape).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_engine::core::{CellRef, Value};
|
||||
use gridcraft_engine::model::{Cell, Formula, Workbook};
|
||||
use gridcraft_engine::{DocState, Session, io};
|
||||
use serde_json::json;
|
||||
|
||||
fn at(s: &str) -> CellRef {
|
||||
CellRef::parse(s).unwrap()
|
||||
}
|
||||
|
||||
/// A workbook saved by GridCraft whose D1 formula came from a file with `cm`.
|
||||
fn opened() -> Session {
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
for r in 0..3 {
|
||||
sh.set_value(CellRef::new(r, 1), Value::Number(f64::from(r + 1)));
|
||||
sh.set_value(CellRef::new(r, 2), Value::Number(f64::from(r + 2)));
|
||||
}
|
||||
let mut f = Formula::new("SUMPRODUCT(B1:B3*C1:C3)");
|
||||
f.dynamic = true;
|
||||
sh.cells.set(at("D1"), Cell::formula(f));
|
||||
let bytes = io::save_bytes(&wb, "dynamic.xlsx").unwrap();
|
||||
let (back, _) = io::open_bytes("dynamic.xlsx", &bytes).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.add_document(DocState::new(back, None, "dynamic".into()));
|
||||
s
|
||||
}
|
||||
|
||||
fn formula(s: &Session, cell: &str) -> Arc<Formula> {
|
||||
s.doc().unwrap().wb.sheet(0).unwrap().cell(at(cell)).unwrap().formula.clone().unwrap()
|
||||
}
|
||||
|
||||
/// Whether `cell` of the saved file carries dynamic-array metadata.
|
||||
fn saved_dynamic(s: &Session, cell: &str) -> bool {
|
||||
let bytes = io::save_bytes(&s.doc().unwrap().wb, "dynamic.xlsx").unwrap();
|
||||
let (back, _) = io::open_bytes("dynamic.xlsx", &bytes).unwrap();
|
||||
back.sheet(0).unwrap().cell(at(cell)).unwrap().formula.as_ref().unwrap().dynamic
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opened_dynamic_formula_keeps_its_metadata_on_save() {
|
||||
let s = opened();
|
||||
assert!(formula(&s, "D1").dynamic);
|
||||
assert_eq!(s.doc().unwrap().wb.sheet(0).unwrap().value(at("D1")), Value::Number(20.0));
|
||||
assert!(saved_dynamic(&s, "D1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserting_rows_and_moving_keep_the_mark() {
|
||||
let mut s = opened();
|
||||
s.execute("home.insertRows", json!({"rows": "1:1"})).unwrap();
|
||||
let f = formula(&s, "D2");
|
||||
assert!(f.dynamic);
|
||||
assert_eq!(f.text, "SUMPRODUCT(B2:B4*C2:C4)");
|
||||
assert!(saved_dynamic(&s, "D2"));
|
||||
s.execute("selection.set", json!({"range": "D2"})).unwrap();
|
||||
s.execute("edit.cut", json!({})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "F2"})).unwrap();
|
||||
s.execute("edit.paste", json!({})).unwrap();
|
||||
assert!(formula(&s, "F2").dynamic);
|
||||
assert!(saved_dynamic(&s, "F2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retyped_formula_drops_the_mark() {
|
||||
let mut s = opened();
|
||||
s.execute("cell.set", json!({"cell": "D1", "input": "=SUMPRODUCT(B1:B3*C1:C3)"})).unwrap();
|
||||
assert!(!formula(&s, "D1").dynamic);
|
||||
// SUMPRODUCT evaluates its arguments as arrays anyway: saved as a legacy formula.
|
||||
assert!(!saved_dynamic(&s, "D1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_mark_is_serialized_only_when_set() {
|
||||
let s = opened();
|
||||
let dynamic = serde_json::to_value(&*formula(&s, "D1")).unwrap();
|
||||
assert_eq!(dynamic["dynamic"], json!(true));
|
||||
let plain = serde_json::to_value(Formula::new("SUM(A1:A3)")).unwrap();
|
||||
assert!(plain.get("dynamic").is_none(), "{plain}");
|
||||
// Documents written before the field existed still load.
|
||||
let old: Formula = serde_json::from_value(json!({"text": "SUM(A1:A3)", "array": null})).unwrap();
|
||||
assert!(!old.dynamic);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn table() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range":"A1", "values":[["Item", "Qty"], ["x", "7"], ["y", "8"]]})).unwrap();
|
||||
s.execute("insert.table", json!({"range":"A1:B3", "header":true, "name":"T"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn styles(s: &Session) -> Vec<String> {
|
||||
s.doc().unwrap().wb.active().unwrap().tables.iter().map(|t| t.style.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_as_table_inside_a_table_restyles_it() {
|
||||
let mut s = table();
|
||||
s.execute("selection.set", json!({"range":"B2"})).unwrap();
|
||||
s.execute("home.formatAsTable", json!({"style":"TableStyleLight9"})).unwrap();
|
||||
assert_eq!(styles(&s), ["TableStyleLight9"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_as_table_on_a_range_inside_a_table_restyles_it() {
|
||||
let mut s = table();
|
||||
s.execute("home.formatAsTable", json!({"range":"A2:B3", "style":"TableStyleDark3"})).unwrap();
|
||||
assert_eq!(styles(&s), ["TableStyleDark3"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_as_table_across_a_table_edge_is_rejected() {
|
||||
let mut s = table();
|
||||
let e = s.execute("home.formatAsTable", json!({"range":"A1:C3", "style":"TableStyleLight9"})).unwrap_err();
|
||||
assert!(e.to_string().contains("overlap"), "{e}");
|
||||
assert_eq!(styles(&s), ["TableStyleMedium2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_as_table_inside_a_table_returns_that_table() {
|
||||
let mut s = table();
|
||||
let r = s.execute("home.formatAsTable", json!({"range":"B2", "style":"TableStyleLight9"})).unwrap();
|
||||
assert_eq!(r, json!({"table":"T", "range":"A1:B3"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_as_table_uses_the_active_area_of_a_multi_area_selection() {
|
||||
let mut s = table();
|
||||
s.execute("selection.set", json!({"range":"D10:E11,B2"})).unwrap();
|
||||
s.execute("home.formatAsTable", json!({"style":"TableStyleLight9"})).unwrap();
|
||||
assert_eq!(styles(&s), ["TableStyleLight9"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_as_table_inside_a_table_with_data_beside_it_restyles_it() {
|
||||
let mut s = table();
|
||||
s.execute("range.setValues", json!({"range":"C1", "values":[["Note"], ["a"], ["b"]]})).unwrap();
|
||||
s.execute("selection.set", json!({"range":"B2"})).unwrap();
|
||||
s.execute("home.formatAsTable", json!({"style":"TableStyleLight9"})).unwrap();
|
||||
assert_eq!(styles(&s), ["TableStyleLight9"]);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Sheet navigation must never activate hidden sheets when it reaches a workbook boundary.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_engine::{Session, model::Visibility};
|
||||
use serde_json::json;
|
||||
|
||||
fn workbook(hidden: &[usize], active: usize) -> Session {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
for name in ["Second", "Third", "Fourth"] {
|
||||
session.execute("home.insertSheet", json!({"name": name})).unwrap();
|
||||
}
|
||||
for sheet in hidden {
|
||||
session.execute("sheet.hide", json!({"sheet": sheet})).unwrap();
|
||||
}
|
||||
session.execute("sheet.activate", json!({"sheet": active})).unwrap();
|
||||
session.execute("selection.set", json!({"cell": "C7"})).unwrap();
|
||||
session
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_stays_on_the_last_visible_sheet() {
|
||||
let mut session = workbook(&[2, 3], 1);
|
||||
for _ in 0..2 {
|
||||
session.execute("sheet.next", json!({})).unwrap();
|
||||
let doc = session.active().unwrap();
|
||||
assert_eq!(doc.wb.active_sheet, 1);
|
||||
assert_eq!(doc.selection.active.a1(), "C7");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previous_stays_on_the_first_visible_sheet() {
|
||||
let mut session = workbook(&[0, 1], 2);
|
||||
for _ in 0..2 {
|
||||
session.execute("sheet.previous", json!({})).unwrap();
|
||||
let doc = session.active().unwrap();
|
||||
assert_eq!(doc.wb.active_sheet, 2);
|
||||
assert_eq!(doc.selection.active.a1(), "C7");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_skips_hidden_sheets_in_both_directions() {
|
||||
let mut session = workbook(&[1, 2], 0);
|
||||
session.execute("sheet.next", json!({})).unwrap();
|
||||
assert_eq!(session.active().unwrap().wb.active_sheet, 3);
|
||||
session.execute("sheet.previous", json!({})).unwrap();
|
||||
let doc = session.active().unwrap();
|
||||
assert_eq!(doc.wb.active_sheet, 0);
|
||||
assert_eq!(doc.selection.active.a1(), "C7");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_with_one_visible_sheet_is_a_noop() {
|
||||
let mut session = workbook(&[0, 2, 3], 1);
|
||||
for command in ["sheet.previous", "sheet.next"] {
|
||||
session.execute(command, json!({})).unwrap();
|
||||
let doc = session.active().unwrap();
|
||||
assert_eq!(doc.wb.active_sheet, 1);
|
||||
assert_eq!(doc.selection.active.a1(), "C7");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn very_hidden_sheets_are_skipped_at_interior_and_boundary_positions() {
|
||||
let mut session = workbook(&[1, 3], 0);
|
||||
// VeryHidden is an imported XLSX state; sheet.hide only creates Hidden sheets.
|
||||
let wb = Arc::make_mut(&mut session.active_mut().unwrap().wb);
|
||||
for index in [1, 3] {
|
||||
Arc::make_mut(&mut wb.sheets[index]).visibility = Visibility::VeryHidden;
|
||||
}
|
||||
for (command, expected) in [("sheet.next", 2), ("sheet.next", 2), ("sheet.previous", 0)] {
|
||||
session.execute(command, json!({})).unwrap();
|
||||
assert_eq!(session.active().unwrap().wb.active_sheet, expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Flat operator chains as long as Excel allows (#916). `=1+1+…` with 4,096 operands is
|
||||
//! exactly 8,192 characters, Excel's formula limit, and builds a tree 4,095 levels deep on its
|
||||
//! left. Entering, editing, saving and reopening such a formula must neither overflow the stack
|
||||
//! nor fall back to an error: Excel computes it.
|
||||
|
||||
use gridcraft_core::{CellRef, Value};
|
||||
use gridcraft_engine::{DocState, Session, io};
|
||||
use serde_json::json;
|
||||
|
||||
/// Excel's longest flat chain of literals: 4,096 operands, 8,192 characters with the `=`.
|
||||
fn literal_chain(op: &str) -> String {
|
||||
format!("={}", vec!["1"; 4096].join(op))
|
||||
}
|
||||
|
||||
/// `=A1+A2+…+A1500`: 7,893 characters, inside Excel's limit.
|
||||
fn reference_chain() -> String {
|
||||
format!("={}", (1..=1500).map(|r| format!("A{r}")).collect::<Vec<_>>().join("+"))
|
||||
}
|
||||
|
||||
fn value(session: &Session, cell: &str) -> Value {
|
||||
let at = CellRef::parse(cell).expect("cell");
|
||||
session.doc().expect("doc").wb.sheet(0).expect("sheet").value(at)
|
||||
}
|
||||
|
||||
/// Runs `f` on a 1 MiB stack, the size of the Windows main thread the desktop app runs on.
|
||||
fn on_small_stack(f: impl FnOnce() + Send + 'static) {
|
||||
std::thread::Builder::new().stack_size(1 << 20).spawn(f).expect("spawn").join().expect("long chains must not overflow the stack");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn longest_flat_chains_survive_editing_saving_and_reopening() {
|
||||
on_small_stack(|| {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
session.execute("range.setValues", json!({"range": "A1:A1500", "values": vec![[1]; 1500]})).expect("values");
|
||||
session.execute("cell.set", json!({"cell": "C1", "input": literal_chain("+")})).expect("plus chain");
|
||||
session.execute("cell.set", json!({"cell": "C2", "input": literal_chain("-")})).expect("minus chain");
|
||||
session.execute("cell.set", json!({"cell": "C3", "input": literal_chain("&")})).expect("concat chain");
|
||||
session.execute("cell.set", json!({"cell": "C4", "input": reference_chain()})).expect("reference chain");
|
||||
assert_eq!(value(&session, "C1"), Value::Number(4096.0));
|
||||
assert_eq!(value(&session, "C2"), Value::Number(-4094.0));
|
||||
assert_eq!(value(&session, "C3"), Value::text("1".repeat(4096)));
|
||||
assert_eq!(value(&session, "C4"), Value::Number(1500.0));
|
||||
|
||||
// Precedents of a long reference chain still drive recalculation.
|
||||
session.execute("cell.set", json!({"cell": "A750", "input": "101"})).expect("edit precedent");
|
||||
assert_eq!(value(&session, "C4"), Value::Number(1600.0));
|
||||
|
||||
// Evaluate Formula walks every operator of the chain.
|
||||
let steps = session.execute("formulas.evaluateFormula", json!({"cell": "C4"})).expect("evaluate formula");
|
||||
assert_eq!(steps["steps"].as_array().map(Vec::len), Some(200));
|
||||
assert_eq!(steps["result"], json!(1600.0));
|
||||
assert_eq!(session.execute("formulas.evaluate", json!({"formula": literal_chain("+")})).expect("evaluate"), json!(4096.0));
|
||||
|
||||
// Copy and paste move every reference (Excel: the pasted C4 sums A2:A1501).
|
||||
session.execute("edit.copy", json!({"range": "C4"})).expect("copy");
|
||||
session.execute("edit.paste", json!({"at": "C5"})).expect("paste");
|
||||
assert_eq!(value(&session, "C5"), Value::Number(1599.0));
|
||||
session.execute("edit.undo", json!({})).expect("undo paste");
|
||||
|
||||
// Fill and structural edits rewrite every reference in the chain (Excel: an inserted
|
||||
// row above shifts A1:A1500 to A2:A1501; the sum is unchanged).
|
||||
session.execute("edit.fillRight", json!({"range": "C1:D4"})).expect("fill right");
|
||||
assert_eq!(value(&session, "D1"), Value::Number(4096.0));
|
||||
assert_eq!(value(&session, "D4"), Value::Number(0.0));
|
||||
session.execute("home.insertRows", json!({"rows": "1:1"})).expect("insert row");
|
||||
assert_eq!(value(&session, "C5"), Value::Number(1600.0));
|
||||
session.execute("home.insertColumns", json!({"cols": "A:A"})).expect("insert column");
|
||||
assert_eq!(value(&session, "D5"), Value::Number(1600.0));
|
||||
session.execute("edit.undo", json!({})).expect("undo");
|
||||
session.execute("edit.undo", json!({})).expect("undo");
|
||||
assert_eq!(value(&session, "C4"), Value::Number(1600.0));
|
||||
session.execute("edit.redo", json!({})).expect("redo");
|
||||
assert_eq!(value(&session, "C5"), Value::Number(1600.0));
|
||||
|
||||
// XLSX round trip keeps the formulas and their values.
|
||||
let wb = &session.doc().expect("doc").wb;
|
||||
let bytes = io::save_bytes(wb, "chains.xlsx").expect("save");
|
||||
let (reopened, _) = io::open_bytes("chains.xlsx", &bytes).expect("open");
|
||||
let mut session = Session::new();
|
||||
session.add_document(DocState::new(reopened, None, "chains".into()));
|
||||
session.execute("formulas.calculateNow", json!({})).expect("recalc");
|
||||
assert_eq!(value(&session, "C2"), Value::Number(4096.0));
|
||||
assert_eq!(value(&session, "C3"), Value::Number(-4094.0));
|
||||
assert_eq!(value(&session, "C5"), Value::Number(1600.0));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//! The crate's integration tests, one module per file, built as one test binary.
|
||||
|
||||
mod autofit_filter_buttons;
|
||||
mod cf_formula_validate;
|
||||
mod chart_workbook_names;
|
||||
mod command_sequence_fuzz;
|
||||
mod cross_sheet_cut;
|
||||
mod csv_export_budget;
|
||||
mod custom_filter_blanks;
|
||||
mod custom_view_freeze;
|
||||
mod dynamicmetadata;
|
||||
mod extreme_selection_moves;
|
||||
mod format_as_table_restyle;
|
||||
mod hidden_sheet_navigation;
|
||||
mod ink_roundtrip;
|
||||
mod line_count_overflow;
|
||||
mod long_operator_chains;
|
||||
mod offset_overflow;
|
||||
mod percent_entry;
|
||||
mod pivot_unknown_func;
|
||||
mod pivotchart_link;
|
||||
mod range_fill_formats;
|
||||
mod row_autofit;
|
||||
mod sheet_name_quoting;
|
||||
mod sparse_clear_invalidation;
|
||||
mod stored_list_shape;
|
||||
mod table_resize_overlap;
|
||||
mod table_total_row_last_row;
|
||||
mod wrap_row_height;
|
||||
@@ -0,0 +1,20 @@
|
||||
//! Typing a bare number into a Percentage-formatted cell enters it as a percent (issue #1967).
|
||||
|
||||
use gridcraft_engine::display::cell_text;
|
||||
use serde_json::json;
|
||||
|
||||
fn enter(input: &str) -> String {
|
||||
let mut s = gridcraft_engine::Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("home.numberFormat", json!({"range": "A1", "format": "Percentage"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": input})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
cell_text(&d.wb, d.wb.active().unwrap(), gridcraft_engine::core::CellRef::new(0, 0))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_number_in_percent_cell_is_a_percent() {
|
||||
assert_eq!(enter("3"), "3.00%");
|
||||
assert_eq!(enter("0.03"), "3.00%");
|
||||
assert_eq!(enter("3%"), "3.00%");
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range":"A1", "values":[["Region", "Sales"], ["East", "10"], ["East", "2"], ["West", "4"]]})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_pivot_table_rejects_unknown_func() {
|
||||
for func in ["stdev", "sumx"] {
|
||||
let mut s = session();
|
||||
let r = s.execute(
|
||||
"insert.pivotTable",
|
||||
json!({"source":"Sheet1!A1:B4", "destination":"Sheet1!F1", "rows":["Region"], "values":[{"field":"Sales", "func":func}]}),
|
||||
);
|
||||
assert!(r.is_err(), "unknown func `{func}` accepted silently");
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().pivots.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_pivot_table_accepts_known_func() {
|
||||
let mut s = session();
|
||||
s.execute(
|
||||
"insert.pivotTable",
|
||||
json!({"source":"Sheet1!A1:B4", "destination":"Sheet1!F1", "rows":["Region"], "values":[{"field":"Sales", "func":"stddev"}]}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().pivots.len(), 1);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//! #1447: a PivotChart is bound to its PivotTable behind the workbook's name.
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Json) -> Json {
|
||||
s.execute(command, params).unwrap()
|
||||
}
|
||||
|
||||
fn source() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
run(
|
||||
&mut s,
|
||||
"range.setValues",
|
||||
json!({"range": "A1", "values": [["Region ", " Sales ", "Qty"], ["East", 10, 1], ["West", 20, 2], ["East", 5, 3]]}),
|
||||
);
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pivot_chart_link_names_the_workbook_in_brackets() {
|
||||
let mut s = source();
|
||||
run(&mut s, "selection.set", json!({"range": "A1"}));
|
||||
run(&mut s, "insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]}));
|
||||
run(&mut s, "insert.pivotChart", json!({}));
|
||||
let bytes = gridcraft_engine::io::save_bytes(&s.doc().unwrap().wb, "Q1 Report.xlsx").unwrap();
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut chart = String::new();
|
||||
zip.by_name("xl/charts/chart1.xml").unwrap().read_to_string(&mut chart).unwrap();
|
||||
assert!(chart.contains("<c:name>[Q1 Report.xlsx]Sheet2!PivotTable1</c:name>"), "{chart}");
|
||||
|
||||
// Without a file name, a placeholder workbook is named; reading drops the brackets again.
|
||||
let saved = run(&mut s, "file.saveBytes", json!({"format": "xlsx"}));
|
||||
let mut reopened = Session::new();
|
||||
run(&mut reopened, "file.open", json!({"name": "q.xlsx", "base64": saved["base64"]}));
|
||||
let link = reopened.doc().unwrap().wb.sheets.iter().flat_map(|sh| sh.charts.iter()).find_map(|c| c.pivot.clone());
|
||||
assert_eq!(link.as_deref(), Some("Sheet2!PivotTable1"));
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Ctrl+Enter (`range.fill`) gives every cell what typing the input there gives it: a General
|
||||
//! cell gets the input's automatic number format, a formatted cell keeps its own.
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s
|
||||
}
|
||||
|
||||
fn get(s: &mut Session, a: &str) -> Value {
|
||||
s.execute("cell.get", json!({"cell": a})).unwrap()
|
||||
}
|
||||
|
||||
fn fmt_of(s: &Session, a: &str) -> String {
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
let sh = wb.active().unwrap();
|
||||
wb.styles.get(sh.style_id(gridcraft_engine::core::CellRef::parse(a).unwrap())).num_fmt.as_str().to_string()
|
||||
}
|
||||
|
||||
/// Selects `range` with A1 active and fills it with `input`, as Ctrl+Enter does.
|
||||
fn fill(s: &mut Session, range: &str, input: &str) {
|
||||
s.execute("selection.set", json!({"range": range, "active": "A1"})).unwrap();
|
||||
s.execute("range.fill", json!({"input": input})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filled_cells_that_already_hold_a_value_get_the_automatic_format() {
|
||||
for input in ["5%", "$5", "3/4/2026", "1:30 PM", "1.5E+3"] {
|
||||
let mut s = session();
|
||||
// What typing the input into a General cell gives.
|
||||
s.execute("cell.set", json!({"cell": "C1", "input": input})).unwrap();
|
||||
let typed = get(&mut s, "C1");
|
||||
// A1 blank, A2 holding a number: both are General.
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "1"})).unwrap();
|
||||
fill(&mut s, "A1:A2", input);
|
||||
for a in ["A1", "A2"] {
|
||||
let filled = get(&mut s, a);
|
||||
assert_eq!(filled["text"], typed["text"], "{input} in {a}");
|
||||
assert_eq!(filled["value"], typed["value"], "{input} in {a}");
|
||||
assert_eq!(fmt_of(&s, a), fmt_of(&s, "C1"), "{input} in {a}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filled_cells_with_their_own_format_keep_it() {
|
||||
let mut s = session();
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "1"})).unwrap();
|
||||
s.execute("home.formatCells", json!({"range": "A2", "style": {"num_fmt": "0.000"}})).unwrap();
|
||||
// Column B is formatted but has no cells yet; row 3 is Text.
|
||||
s.execute("home.formatCells", json!({"range": "B:B", "style": {"num_fmt": "0.00"}})).unwrap();
|
||||
s.execute("home.formatCells", json!({"range": "3:3", "style": {"num_fmt": "@"}})).unwrap();
|
||||
fill(&mut s, "A1:B3", "5%");
|
||||
assert_eq!(fmt_of(&s, "A1"), "0%");
|
||||
assert_eq!(get(&mut s, "A1")["text"], "5%");
|
||||
assert_eq!(fmt_of(&s, "A2"), "0.000");
|
||||
assert_eq!(get(&mut s, "A2")["text"], "0.050");
|
||||
assert_eq!(fmt_of(&s, "B1"), "0.00");
|
||||
assert_eq!(get(&mut s, "B1")["text"], "0.05");
|
||||
// A Text cell keeps what was typed, as text.
|
||||
for a in ["A3", "B3"] {
|
||||
assert_eq!(get(&mut s, a)["type"], "text", "{a}");
|
||||
assert_eq!(get(&mut s, a)["text"], "5%", "{a}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filled_formulas_still_shift_and_get_their_date_format() {
|
||||
let mut s = session();
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "1"})).unwrap();
|
||||
fill(&mut s, "A1:A2", "=DATE(2026,3,ROW())");
|
||||
assert_eq!(get(&mut s, "A2")["formula"], "=DATE(2026,3,ROW())");
|
||||
for (a, text) in [("A1", "3/1/2026"), ("A2", "3/2/2026")] {
|
||||
assert_eq!(fmt_of(&s, a), "m/d/yyyy", "{a}");
|
||||
assert_eq!(get(&mut s, a)["text"], text, "{a}");
|
||||
}
|
||||
fill(&mut s, "A1:A2", "=C1*2");
|
||||
assert_eq!(get(&mut s, "A2")["formula"], "=C2*2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fill_undoes_in_one_step() {
|
||||
let mut s = session();
|
||||
s.execute("cell.set", json!({"cell": "A2", "input": "1"})).unwrap();
|
||||
fill(&mut s, "A1:A2", "5%");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(get(&mut s, "A1")["type"], "empty");
|
||||
assert_eq!(get(&mut s, "A2")["value"].as_f64(), Some(1.0));
|
||||
assert_eq!(fmt_of(&s, "A2"), "General");
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! #1458: sheet names are quoted in references the way Excel quotes them, in formulas, saved
|
||||
//! files, PivotTable sources and ADDRESS.
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::core::{CellRef, Value};
|
||||
use serde_json::{Value as Json, json};
|
||||
|
||||
fn run(s: &mut Session, command: &str, params: Json) -> Json {
|
||||
s.execute(command, params).unwrap()
|
||||
}
|
||||
|
||||
fn formula(s: &Session, cell: &str) -> String {
|
||||
s.doc().unwrap().wb.active().unwrap().cell(CellRef::parse(cell).unwrap()).unwrap().formula.as_ref().unwrap().text.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formulas_keep_the_quotes_excel_needs_through_a_save() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
for name in [".x", "R1C1", "A1", "Bob's"] {
|
||||
run(&mut s, "home.insertSheet", json!({"name": name}));
|
||||
}
|
||||
run(&mut s, "sheet.activate", json!({"sheet": "Sheet1"}));
|
||||
let input = "='.x'!A1+'R1C1'!A1+'A1'!B1+'Bob''s'!A1";
|
||||
run(&mut s, "cell.set", json!({"cell": "A1", "input": input}));
|
||||
assert_eq!(formula(&s, "A1"), input.trim_start_matches('='));
|
||||
|
||||
let saved = run(&mut s, "file.saveBytes", json!({"format": "xlsx"}));
|
||||
let mut reopened = Session::new();
|
||||
run(&mut reopened, "file.open", json!({"name": "q.xlsx", "base64": saved["base64"]}));
|
||||
run(&mut reopened, "sheet.activate", json!({"sheet": "Sheet1"}));
|
||||
assert_eq!(formula(&reopened, "A1"), input.trim_start_matches('='));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_and_pivot_source_quote_a_sheet_named_like_a_cell() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
run(&mut s, "home.insertSheet", json!({"name": "A1"}));
|
||||
run(&mut s, "sheet.activate", json!({"sheet": "A1"}));
|
||||
for (cell, input) in [("A1", "K"), ("B1", "V"), ("A2", "a"), ("B2", "1"), ("A3", "b"), ("B3", "2")] {
|
||||
run(&mut s, "cell.set", json!({"cell": cell, "input": input}));
|
||||
}
|
||||
run(&mut s, "cell.set", json!({"cell": "D1", "input": "=ADDRESS(1,1,1,TRUE,\"A1\")"}));
|
||||
let d1 = s.doc().unwrap().wb.active().unwrap().value(CellRef::parse("D1").unwrap());
|
||||
assert_eq!(d1, Value::from("'A1'!$A$1"));
|
||||
|
||||
run(&mut s, "selection.set", json!({"range": "A2"}));
|
||||
let pivot = run(&mut s, "insert.pivotTable", json!({"rows": ["K"], "values": ["V"]}));
|
||||
assert_eq!(pivot["source"], json!("'A1'!$A$1:$B$3"));
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Clearing a large sparse selection must invalidate every changed cell (issue #1032).
|
||||
|
||||
use gridcraft_core::{CellError, CellRef, Value};
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn value(s: &Session, address: &str) -> Value {
|
||||
s.doc().unwrap().wb.active().unwrap().value(CellRef::parse(address).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_clear_updates_distant_direct_and_transitive_dependents() {
|
||||
for command in ["edit.clearContents", "edit.clearAll"] {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
for (cell, input) in [("A1000001", "7"), ("B1", "=A1000001"), ("C1", "=B1*2")] {
|
||||
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
|
||||
}
|
||||
assert_eq!((value(&s, "B1"), value(&s, "C1")), (Value::Number(7.0), Value::Number(14.0)));
|
||||
|
||||
// B1 stretches the used range to row 1; the changed source lies just past the old cap.
|
||||
s.execute(command, json!({"range": "A1:A1000001"})).unwrap();
|
||||
assert_eq!(value(&s, "A1000001"), Value::Empty, "{command}");
|
||||
assert_eq!((value(&s, "B1"), value(&s, "C1")), (Value::Number(0.0), Value::Number(0.0)), "{command}");
|
||||
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(value(&s, "A1000001"), Value::Number(7.0), "{command} undo");
|
||||
assert_eq!((value(&s, "B1"), value(&s, "C1")), (Value::Number(7.0), Value::Number(14.0)), "{command} undo");
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(value(&s, "A1000001"), Value::Empty, "{command} redo");
|
||||
assert_eq!((value(&s, "B1"), value(&s, "C1")), (Value::Number(0.0), Value::Number(0.0)), "{command} redo");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_clear_removes_a_distant_formulas_spill_and_updates_its_dependent() {
|
||||
for command in ["edit.clearContents", "edit.clearAll"] {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", json!({"cell": "A1000001", "input": "=SEQUENCE(2)"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "=A1000002"})).unwrap();
|
||||
assert_eq!((value(&s, "A1000002"), value(&s, "B1")), (Value::Number(2.0), Value::Number(2.0)));
|
||||
|
||||
// The spill lies outside the selection, but must disappear when its anchor is cleared.
|
||||
s.execute(command, json!({"range": "A1:A1000001"})).unwrap();
|
||||
assert_eq!(value(&s, "A1000001"), Value::Empty, "{command}");
|
||||
assert_eq!(value(&s, "A1000002"), Value::Empty, "{command}");
|
||||
assert_eq!(value(&s, "B1"), Value::Number(0.0), "{command}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_an_empty_merge_unblocks_a_spill() {
|
||||
for command in ["edit.clearFormats", "edit.clearAll"] {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(3)"})).unwrap();
|
||||
// Keep the empty merged area inside the used range after the spill becomes blocked.
|
||||
s.execute("cell.set", json!({"cell": "C4", "input": "1"})).unwrap();
|
||||
s.execute("home.mergeCells", json!({"range": "A2:B3"})).unwrap();
|
||||
assert_eq!(value(&s, "A1"), Value::Error(CellError::Spill));
|
||||
|
||||
s.execute(command, json!({"range": "A2:B3"})).unwrap();
|
||||
assert_eq!((value(&s, "A1"), value(&s, "A3")), (Value::Number(1.0), Value::Number(3.0)), "{command}");
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().merges.is_empty(), "{command}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_a_distant_styled_blocker_unblocks_a_spill() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(100)"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A90", "input": "x"})).unwrap();
|
||||
s.execute("home.bold", json!({"range": "A90", "on": true})).unwrap();
|
||||
assert_eq!(value(&s, "A1"), Value::Error(CellError::Spill));
|
||||
|
||||
// The cleared blocker stays stored for its style and lies beyond the local 64-row scan.
|
||||
s.execute("edit.clearContents", json!({"range": "A2:A100"})).unwrap();
|
||||
assert_eq!(value(&s, "A1"), Value::Number(1.0));
|
||||
assert_eq!(value(&s, "A90"), Value::Number(90.0));
|
||||
assert_eq!(value(&s, "A100"), Value::Number(100.0));
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
let cell = wb.active().unwrap().cell(CellRef::parse("A90").unwrap()).unwrap();
|
||||
assert_eq!(cell.value, Value::Empty);
|
||||
assert!(wb.styles.get(cell.style).font.bold);
|
||||
}
|
||||
+27
@@ -27,3 +27,30 @@ fn resize_to_overlapping_range_still_works() {
|
||||
s.execute("table.resize", json!({"table":"T", "range":"B1:C2"})).unwrap();
|
||||
assert_eq!(table_range(&s), "B1:C2");
|
||||
}
|
||||
|
||||
fn two_tables() -> Session {
|
||||
let mut s = table();
|
||||
s.execute("range.setValues", json!({"range":"D1", "values":[["P", "Q"], ["y", "8"]]})).unwrap();
|
||||
s.execute("insert.table", json!({"range":"D1:E2", "header":true, "name":"U"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn ranges(s: &Session) -> Vec<String> {
|
||||
s.doc().unwrap().wb.active().unwrap().tables.iter().map(|t| t.range.a1()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_over_another_table_is_rejected() {
|
||||
let mut s = two_tables();
|
||||
assert!(s.execute("table.resize", json!({"table":"T", "range":"A1:E2"})).is_err());
|
||||
assert_eq!(ranges(&s), ["A1:B2", "D1:E2"]);
|
||||
assert!(s.execute("table.resize", json!({"table":"U", "range":"A1:E2"})).is_err());
|
||||
assert_eq!(ranges(&s), ["A1:B2", "D1:E2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_next_to_another_table_still_works() {
|
||||
let mut s = two_tables();
|
||||
s.execute("table.resize", json!({"table":"T", "range":"A1:C3"})).unwrap();
|
||||
assert_eq!(ranges(&s), ["A1:C3", "D1:E2"]);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user