mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-29 05:06:51 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ebdf164c2 | ||
|
|
37e41424ed | ||
|
|
68e58a2eae | ||
|
|
a2cc4b42ca | ||
|
|
23829d8c67 | ||
|
|
49fe41afbf | ||
|
|
4401b3e5c5 | ||
|
|
aa4125952e | ||
|
|
8492416c74 | ||
|
|
47fc3d5d35 | ||
|
|
70018d4e60 | ||
|
|
b402359fa4 | ||
|
|
92c0d1e936 | ||
|
|
27f4c9c7bc | ||
|
|
f5267cb74e | ||
|
|
b77848ee21 | ||
|
|
f8e916bcd4 | ||
|
|
ab6fd48856 | ||
|
|
06a94d42b2 | ||
|
|
7b8aaf9ca7 | ||
|
|
12ee27a177 | ||
|
|
8d562e9b56 | ||
|
|
937cfd1e91 | ||
|
|
46c706a491 | ||
|
|
33ac9e5f64 | ||
|
|
8968e83359 | ||
|
|
f2473d1f33 | ||
|
|
a6757829eb | ||
|
|
29f9959521 | ||
|
|
8e8e2ba73c | ||
|
|
33a5415f0e | ||
|
|
859e654dce | ||
|
|
93cb9828bd | ||
|
|
851583365f | ||
|
|
448c167e68 | ||
|
|
9767efe66e | ||
|
|
5eae222fbc | ||
|
|
7cd0a386aa | ||
|
|
eee872e429 | ||
|
|
e9ffc71c6a | ||
|
|
a10ead1c09 | ||
|
|
59074fdc16 | ||
|
|
90344445d7 | ||
|
|
fb7b23dbad | ||
|
|
620be76873 | ||
|
|
6ac8ad9216 | ||
|
|
28cdf1d187 | ||
|
|
62e52004f8 | ||
|
|
86641d965b | ||
|
|
cc21643b83 | ||
|
|
adcb460a63 | ||
|
|
0e8e7e34e4 | ||
|
|
dcd4d1d13f | ||
|
|
4db6876c07 | ||
|
|
90e89d1b9f | ||
|
|
4ed93c3b2c | ||
|
|
f1015e1e6b | ||
|
|
66ce6be474 | ||
|
|
383bb7ba59 | ||
|
|
6e984bb84d | ||
|
|
4f3853d4a9 | ||
|
|
9b8fde71ee | ||
|
|
d4f256d6ee | ||
|
|
95f8206bb9 | ||
|
|
beade8b734 | ||
|
|
dd5c7da6fc | ||
|
|
6082355c22 | ||
|
|
53e5227085 | ||
|
|
c30fa3f1cf | ||
|
|
c5a53a90ac | ||
|
|
19f4387cca | ||
|
|
83189c0ccd | ||
|
|
217a8907ff | ||
|
|
823c56582d | ||
|
|
8ffff19dda | ||
|
|
27932a2ae4 | ||
|
|
125c8b00e8 | ||
|
|
f1894c6da6 | ||
|
|
e91e7f7a68 | ||
|
|
bb685e3275 | ||
|
|
fda8e7b703 | ||
|
|
82ee02aac4 | ||
|
|
79e85c1b80 | ||
|
|
e6b6b34970 | ||
|
|
d9d09f0baa | ||
|
|
84acf6293b | ||
|
|
4c70a46377 | ||
|
|
f4bd752bdc | ||
|
|
589b59defc | ||
|
|
fefe3a0c27 | ||
|
|
3753d7cf08 | ||
|
|
a8cd6f4b63 | ||
|
|
ebe0138d30 | ||
|
|
11838c58c6 | ||
|
|
f353ec7fa4 | ||
|
|
be003d44af | ||
|
|
a4ca87ab2a | ||
|
|
72e7590c8c | ||
|
|
884585a344 | ||
|
|
08cbb69585 | ||
|
|
80d282e7a5 | ||
|
|
5144bd5b17 | ||
|
|
8108dbccc6 | ||
|
|
8272c991b9 | ||
|
|
a2bf1b3ad5 | ||
|
|
e2a6aacf78 | ||
|
|
77f5799bf1 | ||
|
|
6503df5411 | ||
|
|
99f202b18a | ||
|
|
2b36221890 | ||
|
|
4536bc7fc8 | ||
|
|
ebea0c94d1 | ||
|
|
afb6eca31f | ||
|
|
aa02516162 | ||
|
|
be45ccab31 | ||
|
|
0703575b04 | ||
|
|
499bf2c0a7 | ||
|
|
8d1898866c | ||
|
|
0c488f79b3 | ||
|
|
a9a6a3e247 | ||
|
|
2d0a3f8a47 | ||
|
|
61266958ff | ||
|
|
20d6923cb6 | ||
|
|
c6ab899259 | ||
|
|
20309f6e99 | ||
|
|
9c773166f9 | ||
|
|
623997a19e | ||
|
|
3a800f2a5f | ||
|
|
9972a75f10 | ||
|
|
699f594718 | ||
|
|
b3ad57982e | ||
|
|
84ad5a23cb | ||
|
|
3c4556bb04 | ||
|
|
b112b56424 | ||
|
|
e381a8fd01 | ||
|
|
cc0b7c18d1 | ||
|
|
cba08d1b4b | ||
|
|
fe26486eb5 | ||
|
|
181915ee57 | ||
|
|
0068c8f517 | ||
|
|
387a3a3150 | ||
|
|
b464a7f242 | ||
|
|
4f7f67f65d | ||
|
|
c888e81d71 | ||
|
|
7357a90c6b | ||
|
|
2f33d43546 | ||
|
|
6405b00cf7 | ||
|
|
c7cb91ea53 | ||
|
|
638cbab9a5 | ||
|
|
ca8fc73281 | ||
|
|
6de9cfe4e1 | ||
|
|
13bc5ecd11 | ||
|
|
7e6aa698fa | ||
|
|
2174db2006 | ||
|
|
a6f8818794 | ||
|
|
94ee7efdf9 | ||
|
|
cbdb2cc794 | ||
|
|
2ea8002002 | ||
|
|
06203faa9d | ||
|
|
b2e2df3fc9 | ||
|
|
33ad877b6d | ||
|
|
62b7ba1e49 | ||
|
|
b869863699 | ||
|
|
49cb977a73 | ||
|
|
be4d754ef4 | ||
|
|
5055373149 | ||
|
|
8e60c2af80 | ||
|
|
d9e13a953f | ||
|
|
599fc0fe6d | ||
|
|
6dd8f7abe8 | ||
|
|
0d4402ba51 | ||
|
|
a67b5d2960 | ||
|
|
76927382bc | ||
|
|
167ba9b370 | ||
|
|
8488b039a2 | ||
|
|
e5f31a02f3 | ||
|
|
e897ebd93b | ||
|
|
d2bd177b98 | ||
|
|
8b75c7c29f | ||
|
|
773bd6c1ae | ||
|
|
727bd37983 | ||
|
|
04428cdfed | ||
|
|
c8b01a6c37 | ||
|
|
6b28a97f63 | ||
|
|
a8cb5feafd | ||
|
|
29775397ef | ||
|
|
8fa9a652bc | ||
|
|
7ebda2525c | ||
|
|
bf4c88f3db | ||
|
|
bea16deba8 | ||
|
|
1d716e85b1 | ||
|
|
7415d3b37d | ||
|
|
378382b236 | ||
|
|
f075c63207 | ||
|
|
94ccbcd048 | ||
|
|
6a3b462176 | ||
|
|
c7f04be6d3 | ||
|
|
7baa1664ac | ||
|
|
2a443bb619 | ||
|
|
588e289901 | ||
|
|
eb5f366786 | ||
|
|
0a0e5add96 | ||
|
|
19362287bb | ||
|
|
ad11eb3109 | ||
|
|
ed7681ae0c | ||
|
|
45221f5243 | ||
|
|
f0e158f089 | ||
|
|
147db777b1 | ||
|
|
d14269a001 | ||
|
|
317df69d8c | ||
|
|
ad0370dada | ||
|
|
efed7caa01 | ||
|
|
a76759cba6 | ||
|
|
c9b36de54c | ||
|
|
40166aff60 | ||
|
|
1e29e69b5a | ||
|
|
d6bb4bdf7a | ||
|
|
d8561a9ed7 | ||
|
|
83e9314835 | ||
|
|
0837cbc44d | ||
|
|
45973a6804 | ||
|
|
705543521e | ||
|
|
70c84e20ba | ||
|
|
291120a4cd | ||
|
|
5c9e4842d4 | ||
|
|
a435ef6ada | ||
|
|
98c85c6de4 | ||
|
|
1830ea76b1 | ||
|
|
2f38d0162f | ||
|
|
e548a2e705 | ||
|
|
1d875bdf45 | ||
|
|
decc64a59b | ||
|
|
1d4806ff70 | ||
|
|
89f9e24aad | ||
|
|
4cc5ba05fd | ||
|
|
335106e867 | ||
|
|
50853e3def | ||
|
|
b8e000faa6 | ||
|
|
884b1d2b04 | ||
|
|
1bba0ba35c | ||
|
|
91138569fa | ||
|
|
9c4314b070 | ||
|
|
f378bb9cc7 | ||
|
|
02c973536b | ||
|
|
8b845ebb6e | ||
|
|
62100c2542 | ||
|
|
6cddd36f48 | ||
|
|
9f9c0ac4a1 | ||
|
|
284c33a0f5 | ||
|
|
467b2090e4 | ||
|
|
8564ef738b | ||
|
|
8d22483c9f | ||
|
|
87f4932f0e | ||
|
|
e0012d43fa | ||
|
|
e154960ce3 | ||
|
|
8d3e440a8b | ||
|
|
86f83f4ccf | ||
|
|
8891854218 | ||
|
|
24b1b60dbc | ||
|
|
68344a938c | ||
|
|
944e3818f6 | ||
|
|
2d7988d057 | ||
|
|
a668b22833 | ||
|
|
35668da062 | ||
|
|
ca9b3fbbe8 | ||
|
|
5a6341a0cf | ||
|
|
92b1072ab8 | ||
|
|
1868a58a6b | ||
|
|
96897b709c | ||
|
|
79fed48419 | ||
|
|
f91198094c | ||
|
|
591040ce09 | ||
|
|
581a234456 | ||
|
|
d43eefe522 | ||
|
|
81b35afe08 | ||
|
|
31eb549204 | ||
|
|
ed971d8f87 | ||
|
|
f267530b7c | ||
|
|
f4acdcebe5 | ||
|
|
dd165335e1 | ||
|
|
af0a28c0a4 | ||
|
|
3bf29982b7 | ||
|
|
811a8a139a | ||
|
|
2df8dd6392 | ||
|
|
4941bf8da0 | ||
|
|
59872eca3c | ||
|
|
7ea5cd3975 | ||
|
|
e5071f382b | ||
|
|
1fa38e583e | ||
|
|
4d8af67d0e | ||
|
|
c3546f0c97 | ||
|
|
ff16e979cd | ||
|
|
a507761696 | ||
|
|
3794892f28 | ||
|
|
46ba36e896 | ||
|
|
739c5b8545 | ||
|
|
3f5b29a07e | ||
|
|
487f736c8f | ||
|
|
b28a92baa4 | ||
|
|
f6252418d9 | ||
|
|
8474233335 | ||
|
|
c8f249fc97 | ||
|
|
9e3a83464f | ||
|
|
09c719623b | ||
|
|
6de925c1b6 | ||
|
|
2f83a04521 | ||
|
|
e447fb8c90 | ||
|
|
db4fd82595 | ||
|
|
a85638ddd1 | ||
|
|
4164ca557e | ||
|
|
51b1b031d4 | ||
|
|
28c5bf1b06 | ||
|
|
cd985b7d49 | ||
|
|
8f99be0f2e | ||
|
|
522ede0fca | ||
|
|
c186781d8b | ||
|
|
9bb85ab9af | ||
|
|
6dc9138a64 | ||
|
|
95012e03b4 | ||
|
|
bf2923c4ba | ||
|
|
dab4aa5e79 | ||
|
|
34b39e2f3c | ||
|
|
c930bf7872 | ||
|
|
9da4e4cdf7 | ||
|
|
6b6aa82be1 | ||
|
|
b5a82554a9 | ||
|
|
56617b07af | ||
|
|
317ec4388c | ||
|
|
0485e7f4ba | ||
|
|
4d654c5185 | ||
|
|
fd1ed6649b | ||
|
|
cc65915d16 | ||
|
|
fa1dcd73e2 | ||
|
|
2dd832e2d5 | ||
|
|
5564856feb | ||
|
|
26c6e3df44 | ||
|
|
dc3ccc4ea0 | ||
|
|
1086e4aab8 | ||
|
|
a0a17b154c | ||
|
|
805ffba836 | ||
|
|
c5c1763774 | ||
|
|
cf7138b5e1 | ||
|
|
daeda9db47 | ||
|
|
d1260e1878 | ||
|
|
39b3d55f8a | ||
|
|
68a3eabe20 | ||
|
|
443301f9ee | ||
|
|
a38c58ef48 | ||
|
|
9c3db37ca2 | ||
|
|
a7e22d299f | ||
|
|
7a8235a586 | ||
|
|
1f19bf4a27 | ||
|
|
0153457580 | ||
|
|
52b4f873c7 | ||
|
|
484612f638 | ||
|
|
5406fc5629 | ||
|
|
6939d95cae | ||
|
|
75305dfedf | ||
|
|
930fa1cada | ||
|
|
9a58f2d086 | ||
|
|
6e2caa9f86 | ||
|
|
ef1cd55d24 | ||
|
|
a540a7138a | ||
|
|
0042240c05 | ||
|
|
a60b49fad0 | ||
|
|
e5b12dab06 | ||
|
|
5f992be525 | ||
|
|
fd53de7c46 | ||
|
|
2be04524b3 | ||
|
|
a5f531838b | ||
|
|
aa2a580626 | ||
|
|
bd105a569b | ||
|
|
600c25098f | ||
|
|
c4ad8e3a9b | ||
|
|
339f8ed35b | ||
|
|
26ed66cfe1 | ||
|
|
4c2bcd637c | ||
|
|
47c0af3f1b | ||
|
|
531dc5a5aa | ||
|
|
caf3193261 | ||
|
|
5524e2520a | ||
|
|
2797d468c5 | ||
|
|
1767a28371 | ||
|
|
76e7f1de07 | ||
|
|
afce16c8d2 | ||
|
|
4d2e8e3455 | ||
|
|
77a91a714a | ||
|
|
f63a437d5b | ||
|
|
f968bb3269 | ||
|
|
0a27d4a5d4 | ||
|
|
eb534b5caf | ||
|
|
94014c5594 | ||
|
|
f3e05e0ad8 | ||
|
|
a34f106e93 | ||
|
|
dbfed89a36 | ||
|
|
3d0ed97d71 | ||
|
|
0dc76090d7 | ||
|
|
9e28655a8a | ||
|
|
e88d3d7583 | ||
|
|
15b346e6ca | ||
|
|
ea5db94fe3 | ||
|
|
de04cbe917 | ||
|
|
857f538c08 | ||
|
|
1f7fd07c0d | ||
|
|
1a49d18dd1 | ||
|
|
d4874373a4 | ||
|
|
c167d59c70 | ||
|
|
746a1e0364 | ||
|
|
dfb780a544 | ||
|
|
ae54c84bc9 | ||
|
|
3830cee850 | ||
|
|
f7f04df4d7 | ||
|
|
ef15e22815 | ||
|
|
2589f3f7d4 | ||
|
|
bf9a202222 | ||
|
|
0514e537ac | ||
|
|
a33a5741d8 | ||
|
|
7d9c0a4dd9 | ||
|
|
8663be2c32 | ||
|
|
85c7fc4d6e | ||
|
|
9be19c98d8 | ||
|
|
9da1814cdf | ||
|
|
0ccc94edd3 | ||
|
|
ba5e871329 | ||
|
|
5334ae5cbc | ||
|
|
8517c7c48d | ||
|
|
3ceb50f404 | ||
|
|
310cade6e5 | ||
|
|
2ce94fa581 | ||
|
|
91873e03eb | ||
|
|
5fbd726a11 | ||
|
|
dc6f7246a3 | ||
|
|
c20f4c2fcb | ||
|
|
c8a47c9fb3 | ||
|
|
baac184ed8 | ||
|
|
53df51310f | ||
|
|
3846376937 | ||
|
|
2d0fb534a2 | ||
|
|
9a698aa1ff | ||
|
|
9bbb263661 | ||
|
|
7464ce6e6e | ||
|
|
389c539da4 | ||
|
|
5fb854be9c | ||
|
|
125f460cef | ||
|
|
eec3353e83 | ||
|
|
76d3879a94 | ||
|
|
555635e13e | ||
|
|
b039465d41 | ||
|
|
098b9e70b4 | ||
|
|
3301d59f44 | ||
|
|
e6c16472e4 | ||
|
|
bfe3a9483d | ||
|
|
0ae1eafc9b | ||
|
|
ff14224ff9 | ||
|
|
34125d7e43 | ||
|
|
8427681d7c | ||
|
|
2586ec22df | ||
|
|
a98f6d127e | ||
|
|
f8983e25eb | ||
|
|
40dfdd8331 | ||
|
|
576e625de6 | ||
|
|
34e1d373bf | ||
|
|
a06219aeeb | ||
|
|
344888ceb5 | ||
|
|
3f45edf144 | ||
|
|
c13cda2229 | ||
|
|
b5827f4198 | ||
|
|
d64b30b3a4 | ||
|
|
358334c755 | ||
|
|
2eb5ef5b0d | ||
|
|
30fe8c4cae | ||
|
|
1f16c2e268 | ||
|
|
afe9a6301f | ||
|
|
ea6bb98fc7 | ||
|
|
593776f0de | ||
|
|
c5d43a33e6 | ||
|
|
afd61a7c4a | ||
|
|
a4c6ac3eff | ||
|
|
1ea44bf00b | ||
|
|
c7701f6e13 | ||
|
|
88cf4cf92d | ||
|
|
019d5de98e | ||
|
|
fa2b9566fb | ||
|
|
fb7bcefaa4 | ||
|
|
481f1c2b8b | ||
|
|
c59e94297c | ||
|
|
b60d641083 | ||
|
|
3a8f6f5aa5 | ||
|
|
3424229ee3 | ||
|
|
faba681c99 | ||
|
|
fd3d9f615a | ||
|
|
6d47626534 |
@@ -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
|
||||
Generated
+18
-16
@@ -1496,7 +1496,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"craft-print",
|
||||
@@ -1508,6 +1508,8 @@ dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"muda",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"pollster 1.0.1",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
@@ -1516,7 +1518,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-calc"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
@@ -1528,7 +1530,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-chart"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"gridcraft-calc",
|
||||
"gridcraft-core",
|
||||
@@ -1539,7 +1541,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-cli"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"gridcraft-mcp",
|
||||
@@ -1551,7 +1553,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-core"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde",
|
||||
@@ -1561,7 +1563,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-engine"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"gridcraft-calc",
|
||||
"gridcraft-chart",
|
||||
@@ -1582,7 +1584,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-formula"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"serde",
|
||||
@@ -1591,7 +1593,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-functions"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"regex",
|
||||
@@ -1599,7 +1601,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-mcp"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"log",
|
||||
@@ -1608,7 +1610,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-model"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
@@ -1620,18 +1622,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-numfmt"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-pdf"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-ui-egui"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
@@ -1653,7 +1655,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-web"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -1670,7 +1672,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-xlsx"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"gridcraft-core",
|
||||
@@ -4524,7 +4526,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"serde_json",
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.1"
|
||||
version = "0.10.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
<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 +24,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 +70,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 +81,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.
|
||||
@@ -187,7 +190,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 +200,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 +239,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 +337,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">
|
||||
|
||||
+22
-1
@@ -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 (direct desktop printing through craft-print) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **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)
|
||||
|
||||
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.
|
||||
@@ -132,6 +132,21 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 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) |
|
||||
| 2026-10-12 | Security: cell hyperlinks open only http, https and mailto, after a confirmation; other schemes (file:, smb:, UNC, custom handlers) are refused (#777, part 1) |
|
||||
| 2026-10-12 | Deleting several Ctrl-selected rows or columns removes them all in one undo step (#758) |
|
||||
| 2026-10-12 | XLSX chart sheets are kept on open and save: the sheet, its drawing and chart (with style and colours) are written back unchanged in their place among the worksheets, and left out when the sheet they plot is deleted or renamed or a worksheet took their name (#727) |
|
||||
| 2026-10-12 | CSV and tab-separated export is capped at 512 MiB: a sheet whose used range would expand to more (two cells far apart, such as A1 and XFD1048576, are 16 GiB of commas) is refused with the rows, columns and limit, before anything is allocated or written (#528) |
|
||||
| 2026-10-12 | PDF writer hardening: text width cannot overflow, all-zero dash arrays and negative or NaN stroke widths are sanitised, non-finite sizes and positions draw nothing (#812) |
|
||||
| 2026-10-12 | PDF export writes the document title and author as UTF-16BE when they are not ASCII, so viewers show accents, currency signs and non-Latin text correctly (#813) |
|
||||
| 2026-10-12 | `TEXT()` over a range and chart labels parse each number format code once instead of once per value (#806) |
|
||||
| 2026-10-12 | SUBTOTAL(109) over hidden rows recalculates after Row Height, AutoFit Row Height and Ungroup reveal rows (found by the command-sequence fuzz) |
|
||||
| 2026-10-12 | Page Layout view: drag the margin edges on the rulers to set the page margins (resize cursor, live preview, value tooltip, one undo step, Esc cancels) (#792) |
|
||||
| 2026-10-12 | Forecast Sheet gets Forecast Start: begin the forecast inside the history to compare it with the actuals (#785) |
|
||||
| 2026-10-12 | Ink strokes save as unfilled DrawingML paths and reopen as editable ink with their points, colour and width; icons in #551 remain a follow-up |
|
||||
| 2026-10-12 | The selection outline stays visible when extended to the last row (Ctrl+Shift+Down): its sides are cut to the viewport instead of stroking a 20-million-point rectangle (#687) |
|
||||
| 2026-10-12 | File › Print selects a system printer, copies, duplex and grayscale, submits PDF jobs through craft-print on a worker and shows spooler errors; browser printing and PDF export remain available |
|
||||
| 2026-10-11 | Command-sequence fuzzing: random command sequences checked against undo/redo, XLSX round-trip and full recalculation; fixes for the table, legacy-array, spill, AutoSum, Watch Window and sheet-copy bugs it found |
|
||||
| 2026-10-11 | Password-protected workbooks open: GridCraft asks for the password and decrypts Agile and Standard encryption (#11) |
|
||||
@@ -178,6 +193,12 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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) |
|
||||
| 2026-10-12 | trivial | Progress log: CSV export size cap (#528) |
|
||||
| 2026-10-12 | minor | Progress log: margins dragged on the Page Layout rulers (#792) |
|
||||
| 2026-10-12 | minor | Ink preservation across XLSX save/reopen (ink portion of #551) |
|
||||
| 2026-10-12 | minor | Desktop File > Print now discovers printers and submits PDF jobs through pinned craft-print with printer choice, copies, duplex and grayscale; browser printing and PDF export remain available. Remaining print parity includes page ranges and driver-specific tray/options UI; physical printer fidelity needs hardware validation. |
|
||||
| 2026-10-11 | minor | Password-encrypted workbooks open (#11); progress log row |
|
||||
| 2026-10-11 | minor | Forecast Sheet (#322); progress log row |
|
||||
|
||||
+282
-11
@@ -8,8 +8,9 @@
|
||||
//! them are skipped.
|
||||
//!
|
||||
//! **Round trip.** The recalculated workbook is written with GridCraft's XLSX writer, the package is
|
||||
//! validated (content types cover every part, every internal relationship target exists) and read
|
||||
//! back, and the two workbooks are compared feature by feature.
|
||||
//! validated (content types cover every part, every internal relationship target exists, no kind of
|
||||
//! relationship the source had is missing) and read back, and the two workbooks are compared feature
|
||||
//! by feature.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||
use std::io::{Cursor, Read as _};
|
||||
@@ -38,6 +39,9 @@ const MAX_FILES: usize = 100_000;
|
||||
const MAX_XML_PART: u64 = 64 * 1024 * 1024;
|
||||
/// Most zip entries looked at while validating a written package.
|
||||
const MAX_ENTRIES: usize = 200_000;
|
||||
/// Relationship kinds (lower case) whose absence after a save isn't a loss: the calculation chain is
|
||||
/// a cache Excel rebuilds, and GridCraft never writes it.
|
||||
const REBUILT_KINDS: [&str; 1] = ["calcchain"];
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Options {
|
||||
@@ -97,6 +101,9 @@ pub struct CalcReport {
|
||||
/// aren't results, so every formula counts as no oracle.
|
||||
pub stale_cache: bool,
|
||||
pub volatile: usize,
|
||||
/// Matched cells whose producer saved GridCraft's TRUE or FALSE as the number 1 or 0 (no
|
||||
/// `t="b"`, as Business Central's Excel exports do); counted in `matched` too.
|
||||
pub bool_as_number: usize,
|
||||
/// The first `max_report` mismatches, primary ones first.
|
||||
pub mismatches: Vec<Mismatch>,
|
||||
pub functions: BTreeMap<String, FnStat>,
|
||||
@@ -286,7 +293,7 @@ fn check_inner(path: &str, bytes: &[u8], opts: &Options) -> FileReport {
|
||||
calc.recalc_all(&mut fresh);
|
||||
let trust_cache = !full_calc_on_load(bytes);
|
||||
report.calc = oracle(&cached, &fresh, &calc, trust_cache, opts);
|
||||
report.round_trip = round_trip(&fresh, opts);
|
||||
report.round_trip = round_trip(&fresh, bytes, opts);
|
||||
report
|
||||
}
|
||||
|
||||
@@ -347,6 +354,13 @@ fn cell_name(wb: &Workbook, (sheet, c): Key) -> String {
|
||||
format!("{}!{}", quote_sheet(name), c.a1())
|
||||
}
|
||||
|
||||
/// Whether the producer cached GridCraft's logical result as the number 1 or 0. Excel types a
|
||||
/// logical result `t="b"`; a writer that leaves the type out (`<c><f>TRUE()</f><v>1</v></c>`, as
|
||||
/// Business Central's "Open in Excel" exports do) stores the same value, read back as a number.
|
||||
pub fn bool_cached_as_number(expected: &Value, actual: &Value) -> bool {
|
||||
matches!((expected, actual), (Value::Number(n), Value::Bool(b)) if *n == if *b { 1.0 } else { 0.0 })
|
||||
}
|
||||
|
||||
/// Whether GridCraft's `actual` agrees with the producer's `expected`.
|
||||
pub fn values_match(expected: &Value, actual: &Value, tolerance: f64) -> bool {
|
||||
match (expected, actual) {
|
||||
@@ -458,7 +472,11 @@ pub fn oracle(cached: &Workbook, fresh: &Workbook, calc: &Calc, trust_cache: boo
|
||||
}
|
||||
None => vec!["(unparsed)".to_string()],
|
||||
};
|
||||
let ok = values_match(&cell.value, &actual, opts.tolerance);
|
||||
let bool_as_number = bool_cached_as_number(&cell.value, &actual);
|
||||
let ok = bool_as_number || values_match(&cell.value, &actual, opts.tolerance);
|
||||
if bool_as_number {
|
||||
r.bool_as_number += 1;
|
||||
}
|
||||
for n in &funcs {
|
||||
let st = r.functions.entry(n.clone()).or_default();
|
||||
st.checked += 1;
|
||||
@@ -514,8 +532,9 @@ pub fn oracle(cached: &Workbook, fresh: &Workbook, calc: &Calc, trust_cache: boo
|
||||
|
||||
// ---------------------------------------------------------------- round trip
|
||||
|
||||
/// Writes `wb` with GridCraft's XLSX writer, validates the package, reads it back and compares.
|
||||
pub fn round_trip(wb: &Workbook, opts: &Options) -> RoundTrip {
|
||||
/// Writes `wb` with GridCraft's XLSX writer, validates the package against itself and against
|
||||
/// `source` (the file `wb` was read from), reads it back and compares.
|
||||
pub fn round_trip(wb: &Workbook, source: &[u8], opts: &Options) -> RoundTrip {
|
||||
let bytes = match gridcraft_engine::io::save_bytes(wb, "roundtrip.xlsx") {
|
||||
Ok(b) => b,
|
||||
Err(e) => return RoundTrip { error: Some(format!("write failed: {e}")), categories: vec![] },
|
||||
@@ -525,6 +544,13 @@ pub fn round_trip(wb: &Workbook, opts: &Options) -> RoundTrip {
|
||||
Ok(issues) => issues.into_iter().for_each(|i| package.push(|| i)),
|
||||
Err(e) => package.push(|| e),
|
||||
}
|
||||
if let (Ok(before), Ok(after)) = (relationship_kinds(source), relationship_kinds(&bytes)) {
|
||||
for (key, kind) in &before {
|
||||
if !after.contains_key(key) && !REBUILT_KINDS.contains(&key.as_str()) {
|
||||
package.push(|| format!("lost relationship {kind}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let back = match gridcraft_engine::io::open_bytes("roundtrip.xlsx", &bytes) {
|
||||
Ok((b, _)) => b,
|
||||
Err(e) => return RoundTrip { error: Some(format!("reading the written file failed: {e}")), categories: vec![package.done()] },
|
||||
@@ -559,6 +585,14 @@ fn diff_sets(d: &mut Diffs, sheet: &str, a: &BTreeSet<String>, b: &BTreeSet<Stri
|
||||
}
|
||||
}
|
||||
|
||||
/// A sheet's conditional-format rules in precedence order, as comparable text. The priority number
|
||||
/// itself is left out, so renumbering without changing the order isn't a difference.
|
||||
fn cond_format_list(s: &Sheet) -> Vec<String> {
|
||||
let mut cfs: Vec<_> = s.cond_formats.iter().collect();
|
||||
cfs.sort_by_key(|c| c.priority);
|
||||
cfs.iter().map(|c| format!("{:?} {:?} stopIfTrue={}", c.ranges, c.rule, c.stop_if_true)).collect()
|
||||
}
|
||||
|
||||
fn diff_count(d: &mut Diffs, sheet: &str, a: usize, b: usize) {
|
||||
if a != b {
|
||||
d.push(|| format!("{sheet}: {a} → {b}"));
|
||||
@@ -634,7 +668,12 @@ pub fn diff_workbooks(a: &Workbook, b: &Workbook, opts: &Options) -> Vec<Categor
|
||||
.collect()
|
||||
};
|
||||
diff_sets(&mut tables, &name, &set(&table_list, sa), &set(&table_list, sb));
|
||||
diff_count(&mut cond_formats, &name, sa.cond_formats.len(), sb.cond_formats.len());
|
||||
let (cfa, cfb) = (cond_format_list(sa), cond_format_list(sb));
|
||||
if cfa.len() != cfb.len() {
|
||||
cond_formats.push(|| format!("{name}: {} → {}", cfa.len(), cfb.len()));
|
||||
} else if let Some((x, y)) = cfa.iter().zip(&cfb).find(|(x, y)| x != y) {
|
||||
cond_formats.push(|| format!("{name}: {x} → {y}"));
|
||||
}
|
||||
diff_count(&mut validations, &name, sa.validations.len(), sb.validations.len());
|
||||
let chart_list = |s: &Sheet| s.charts.iter().map(|c| format!("{:?} ({} series)", c.kind, c.series.len())).collect::<Vec<_>>();
|
||||
let (ca, cb) = (chart_list(sa), chart_list(sb));
|
||||
@@ -888,6 +927,77 @@ pub fn validate_package(bytes: &[u8]) -> Result<Vec<String>, String> {
|
||||
Ok(issues)
|
||||
}
|
||||
|
||||
/// Every relationship kind (last segment of the type, the same in the transitional and strict
|
||||
/// namespaces) in the package's relationship parts, keyed in lower case. Relationships that carry
|
||||
/// nothing are left out, as a save may rightly drop them: a link to a place in the workbook
|
||||
/// (`#Sheet1!A1`, written as a cell's `location`) and a target part with nothing in it.
|
||||
fn relationship_kinds(bytes: &[u8]) -> Result<BTreeMap<String, String>, String> {
|
||||
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| format!("not a zip package: {e}"))?;
|
||||
let names = zip_names(&mut zip);
|
||||
let by_key: HashMap<String, &String> = names.iter().map(|n| (part_key(n), n)).collect();
|
||||
let mut kinds = BTreeMap::new();
|
||||
for n in names.iter().filter(|n| part_key(n).ends_with(".rels")) {
|
||||
let Some(src_dir) = rels_source_dir(&part_key(n)) else { continue };
|
||||
let Ok(els) = read_zip_part(&mut zip, n).and_then(|xml| xml_elements(&xml)) else { continue };
|
||||
for (_, attrs) in els.iter().filter(|(el, _)| el == "Relationship") {
|
||||
let Some(kind) = attr(attrs, "Type").and_then(|t| t.rsplit('/').next()) else { continue };
|
||||
let target = attr(attrs, "Target").unwrap_or("");
|
||||
if kind.eq_ignore_ascii_case("hyperlink") && target.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let external = attr(attrs, "TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External"));
|
||||
if !external
|
||||
&& let Some(part) = by_key.get(&resolve_target(&src_dir, target).to_ascii_lowercase())
|
||||
&& !holds_something(&mut zip, part, kind)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
kinds.entry(kind.to_ascii_lowercase()).or_insert_with(|| kind.to_string());
|
||||
}
|
||||
}
|
||||
Ok(kinds)
|
||||
}
|
||||
|
||||
/// The directory of the part a rels part describes: `xl` for `xl/_rels/workbook.xml.rels`, the
|
||||
/// empty string for the package's `_rels/.rels`.
|
||||
fn rels_source_dir(key: &str) -> Option<String> {
|
||||
let (dir, _) = key.strip_suffix(".rels")?.rsplit_once('/')?;
|
||||
Some(dir.strip_suffix("_rels")?.trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
/// Whether a part has content: an element under its root, or for comments (whose authors list
|
||||
/// comes first) a comment, for VML a shape. Read only as far as needed; an unreadable part counts.
|
||||
fn holds_something(zip: &mut Zip<'_>, name: &str, kind: &str) -> bool {
|
||||
use quick_xml::events::Event;
|
||||
let Ok(f) = zip.by_name(name) else { return true };
|
||||
let mut reader = quick_xml::Reader::from_reader(std::io::BufReader::new(f.take(MAX_XML_PART)));
|
||||
let wanted: Option<&[u8]> = match kind.to_ascii_lowercase().as_str() {
|
||||
"comments" => Some(b"comment"),
|
||||
"vmldrawing" => Some(b"shape"),
|
||||
_ => None,
|
||||
};
|
||||
let mut buf = Vec::new();
|
||||
let mut in_root = false;
|
||||
loop {
|
||||
match reader.read_event_into(&mut buf) {
|
||||
Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
|
||||
let found = match wanted {
|
||||
Some(w) => local_name(e.name().as_ref()) == w,
|
||||
None => in_root,
|
||||
};
|
||||
if found {
|
||||
return true;
|
||||
}
|
||||
in_root = true;
|
||||
}
|
||||
Ok(Event::Eof) => return false,
|
||||
Ok(_) => {}
|
||||
Err(_) => return true,
|
||||
}
|
||||
buf.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- output
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -901,6 +1011,7 @@ struct Totals {
|
||||
mismatched: usize,
|
||||
no_oracle: usize,
|
||||
volatile: usize,
|
||||
bool_as_number: usize,
|
||||
round_trip_failed: usize,
|
||||
round_trip_differing: usize,
|
||||
categories: BTreeMap<&'static str, usize>,
|
||||
@@ -922,6 +1033,7 @@ fn totals(reports: &[FileReport]) -> Totals {
|
||||
t.mismatched += r.calc.mismatched;
|
||||
t.no_oracle += r.calc.no_oracle;
|
||||
t.volatile += r.calc.volatile;
|
||||
t.bool_as_number += r.calc.bool_as_number;
|
||||
if r.round_trip.error.is_some() {
|
||||
t.round_trip_failed += 1;
|
||||
}
|
||||
@@ -982,7 +1094,8 @@ pub fn report_json(reports: &[FileReport], opts: &Options) -> Json {
|
||||
"warnings": r.warnings,
|
||||
"calc": {
|
||||
"formulaCells": c.formula_cells, "checked": c.checked, "matched": c.matched, "mismatched": c.mismatched,
|
||||
"noOracle": c.no_oracle, "staleCache": c.stale_cache, "volatile": c.volatile, "mismatches": mismatches, "functions": functions_json(&c.functions),
|
||||
"noOracle": c.no_oracle, "staleCache": c.stale_cache, "volatile": c.volatile, "boolAsNumber": c.bool_as_number,
|
||||
"mismatches": mismatches, "functions": functions_json(&c.functions),
|
||||
"signatures": c.signatures,
|
||||
},
|
||||
"roundTrip": {"error": r.round_trip.error, "differing": r.round_trip.differing(), "categories": categories},
|
||||
@@ -996,7 +1109,7 @@ pub fn report_json(reports: &[FileReport], opts: &Options) -> Json {
|
||||
"files": files,
|
||||
"totals": {
|
||||
"files": t.files, "opened": t.opened, "failedToOpen": t.failed, "formulaCells": t.formula_cells, "checked": t.checked,
|
||||
"matched": t.matched, "mismatched": t.mismatched, "noOracle": t.no_oracle, "volatile": t.volatile,
|
||||
"matched": t.matched, "mismatched": t.mismatched, "noOracle": t.no_oracle, "volatile": t.volatile, "boolAsNumber": t.bool_as_number,
|
||||
"roundTripFailed": t.round_trip_failed, "roundTripDiffering": t.round_trip_differing, "categoriesDiffering": t.categories,
|
||||
},
|
||||
"functions": functions_json(&t.functions),
|
||||
@@ -1035,6 +1148,9 @@ fn print_human(reports: &[FileReport], opts: &Options) {
|
||||
if c.stale_cache {
|
||||
outln!(" (the producer asked for a full recalculation on load: its cached values aren't results)");
|
||||
}
|
||||
if c.bool_as_number > 0 {
|
||||
outln!(" ({} matched TRUE/FALSE results were saved by the producer as 1/0, without t=\"b\")", c.bool_as_number);
|
||||
}
|
||||
for m in &c.mismatches {
|
||||
let tag = if m.primary { "mismatch" } else { " (downstream)" };
|
||||
outln!(
|
||||
@@ -1131,6 +1247,9 @@ fn print_human(reports: &[FileReport], opts: &Options) {
|
||||
t.no_oracle,
|
||||
t.volatile
|
||||
);
|
||||
if t.bool_as_number > 0 {
|
||||
outln!("booleans saved as 1/0 by the producer: {} (counted as matched)", t.bool_as_number);
|
||||
}
|
||||
let cats: Vec<String> = t.categories.iter().map(|(c, n)| format!("{c} {n}")).collect();
|
||||
outln!(
|
||||
"round trip: {} of {} files differ{}{}",
|
||||
@@ -1235,6 +1354,31 @@ mod tests {
|
||||
assert_eq!(check(&wb, &Options { tolerance: 1e-12, ..Default::default() }).calc.mismatched, 1);
|
||||
}
|
||||
|
||||
/// Business Central writes TRUE and FALSE as `<c><f>TRUE()</f><v>1</v></c>`, without `t="b"`:
|
||||
/// the cache holds the right value under a number type, so it matches and is counted apart.
|
||||
#[test]
|
||||
fn booleans_cached_as_numbers_match_and_are_counted_apart() {
|
||||
let wb = book(&[
|
||||
("A1", "=TRUE()", n(1.0)),
|
||||
("B1", "=FALSE()", n(0.0)),
|
||||
("C1", "=TRUE()", Value::Bool(true)),
|
||||
("D1", "=1=1", n(0.0)),
|
||||
("E1", "=TRUE()+0", n(1.0)),
|
||||
]);
|
||||
let r = check(&wb, &Options::default());
|
||||
assert_eq!((r.calc.checked, r.calc.matched, r.calc.mismatched), (5, 4, 1));
|
||||
// A cached 0 for TRUE is still a mismatch, and a number result is compared as a number.
|
||||
assert_eq!(r.calc.mismatches[0].cell, "Sheet1!D1");
|
||||
assert_eq!(r.calc.signatures.get("number → bool"), Some(&1));
|
||||
assert_eq!(r.calc.bool_as_number, 2);
|
||||
let v = report_json(std::slice::from_ref(&r), &Options::default());
|
||||
assert_eq!((v["files"][0]["calc"]["boolAsNumber"].clone(), v["totals"]["boolAsNumber"].clone()), (json!(2), json!(2)));
|
||||
assert!(bool_cached_as_number(&n(1.0), &Value::Bool(true)));
|
||||
assert!(!bool_cached_as_number(&n(1.0), &Value::Bool(false)));
|
||||
assert!(!bool_cached_as_number(&n(2.0), &Value::Bool(true)));
|
||||
assert!(!bool_cached_as_number(&Value::Bool(true), &n(1.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_cached_values_and_volatile_formulas_are_skipped() {
|
||||
let wb =
|
||||
@@ -1277,7 +1421,8 @@ mod tests {
|
||||
fn round_trip_of_a_simple_book_is_clean() {
|
||||
let mut wb = book(&[("A1", "2", n(2.0)), ("B1", "=A1*3", n(6.0)), ("A2", "x", Value::text("x"))]);
|
||||
wb.sheet_mut(0).unwrap().merges.push(gridcraft_engine::core::RangeRef::parse("C1:D2").unwrap());
|
||||
let rt = round_trip(&wb, &Options::default());
|
||||
let source = gridcraft_engine::io::save_bytes(&wb, "t.xlsx").unwrap();
|
||||
let rt = round_trip(&wb, &source, &Options::default());
|
||||
assert_eq!(rt.error, None);
|
||||
assert!(rt.is_clean(), "{rt:?}");
|
||||
assert!(rt.categories.iter().any(|c| c.name == "package"));
|
||||
@@ -1303,6 +1448,27 @@ mod tests {
|
||||
assert_eq!(get("tables").diffs, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_detects_changed_cond_format_rule() {
|
||||
use gridcraft_engine::model::{CfRule, CfValueKind, Color, CondFormat};
|
||||
let bar = |gradient: bool, priority: u32| CondFormat {
|
||||
ranges: vec![gridcraft_engine::core::RangeRef::parse("A1").unwrap()],
|
||||
rule: CfRule::DataBar { min: CfValueKind::Min, max: CfValueKind::Max, color: Color::Rgb(0x638EC6), gradient, show_value: true },
|
||||
priority,
|
||||
stop_if_true: false,
|
||||
};
|
||||
let with = |cf: CondFormat| {
|
||||
let mut wb = book(&[("A1", "2", n(2.0))]);
|
||||
wb.sheet_mut(0).unwrap().cond_formats.push(cf);
|
||||
wb
|
||||
};
|
||||
let diffs =
|
||||
|a: &Workbook, b: &Workbook| diff_workbooks(a, b, &Options::default()).into_iter().find(|c| c.name == "condFormats").unwrap().diffs;
|
||||
assert_eq!(diffs(&with(bar(false, 1)), &with(bar(true, 1))), 1);
|
||||
assert_eq!(diffs(&with(bar(true, 1)), &with(bar(true, 1))), 0);
|
||||
assert_eq!(diffs(&with(bar(true, 1)), &with(bar(true, 5))), 0);
|
||||
}
|
||||
|
||||
fn zip_of(parts: &[(&str, &str)]) -> Vec<u8> {
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for (name, body) in parts {
|
||||
@@ -1342,6 +1508,98 @@ mod tests {
|
||||
assert_eq!(validate_package(&own).unwrap(), Vec::<String>::new());
|
||||
}
|
||||
|
||||
const WB_RELS: &str = "xl/_rels/workbook.xml.rels";
|
||||
const SHEET_RELS: &str = "xl/worksheets/_rels/sheet1.xml.rels";
|
||||
const MADE_UP: &str = r#"<Relationship Id="rId90" Type="http://example.com/relationships/madeUp" Target="madeUp.xml"/>"#;
|
||||
/// Not empty: an empty part isn't a loss.
|
||||
const MADE_UP_PART: (&str, &str) = ("xl/madeUp.xml", "<madeUp><item/></madeUp>");
|
||||
|
||||
/// `bytes` with relationships added to rels parts (created when missing) and `parts` added.
|
||||
fn with_rels(bytes: &[u8], rels: &[(&str, &str)], parts: &[(&str, &str)]) -> Vec<u8> {
|
||||
let mut src = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut pending: Vec<(&str, &str)> = rels.to_vec();
|
||||
for i in 0..src.len() {
|
||||
let mut f = src.by_index(i).unwrap();
|
||||
let name = f.name().to_string();
|
||||
let mut body = String::new();
|
||||
f.read_to_string(&mut body).unwrap();
|
||||
if let Some(at) = pending.iter().position(|(r, _)| *r == name) {
|
||||
body = body.replace("</Relationships>", &format!("{}</Relationships>", pending.remove(at).1));
|
||||
}
|
||||
out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
out.write_all(body.as_bytes()).unwrap();
|
||||
}
|
||||
let created = pending.iter().map(|(r, extra)| {
|
||||
(*r, format!(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">{extra}</Relationships>"#))
|
||||
});
|
||||
for (name, body) in created.collect::<Vec<_>>().iter().map(|(n, b)| (*n, b.as_str())).chain(parts.iter().copied()) {
|
||||
out.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
out.write_all(body.as_bytes()).unwrap();
|
||||
}
|
||||
out.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// The `package` examples of checking `source`.
|
||||
fn package_of(source: &[u8]) -> Vec<String> {
|
||||
let r = check_bytes("t.xlsx", source, &Options::default());
|
||||
r.round_trip.categories.iter().find(|c| c.name == "package").unwrap().examples.clone()
|
||||
}
|
||||
|
||||
fn plain() -> Vec<u8> {
|
||||
gridcraft_engine::io::save_bytes(&book(&[("A1", "1", n(1.0))]), "t.xlsx").unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_reports_relationship_kinds_the_output_lost() {
|
||||
// A kind GridCraft doesn't know is dropped on save; a calculation chain is never written
|
||||
// (Excel rebuilds it), so its absence isn't a loss.
|
||||
let calc_chain = r#"<Relationship Id="rId91" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/calcChain" Target="calcChain.xml"/>"#;
|
||||
let source = with_rels(&plain(), &[(WB_RELS, &format!("{MADE_UP}{calc_chain}"))], &[MADE_UP_PART, ("xl/calcChain.xml", "<calcChain/>")]);
|
||||
assert_eq!(package_of(&source), ["lost relationship madeUp"]);
|
||||
assert!(check_bytes("t.xlsx", &plain(), &Options::default()).round_trip.is_clean());
|
||||
}
|
||||
|
||||
// In the three tests below the made-up kind stays lost, so the comparison is known to run.
|
||||
|
||||
#[test]
|
||||
fn an_internal_link_written_as_a_relationship_is_not_a_loss() {
|
||||
// openpyxl writes `#Sheet1!B2` as an external relationship; GridCraft writes `location`.
|
||||
let link = r##"<Relationship Id="rId5" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="#Sheet1!B2" TargetMode="External"/>"##;
|
||||
let source = with_rels(&plain(), &[(WB_RELS, MADE_UP), (SHEET_RELS, link)], &[MADE_UP_PART]);
|
||||
assert_eq!(package_of(&source), ["lost relationship madeUp"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_without_a_comment_are_not_a_loss() {
|
||||
let rels = r#"<Relationship Id="rId6" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="../comments1.xml"/><Relationship Id="rId7" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing" Target="../drawings/vmlDrawing1.vml"/>"#;
|
||||
let comments = r#"<comments xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><authors><author>A</author></authors><commentList/></comments>"#;
|
||||
let vml = r#"<xml xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office"><o:shapelayout><o:idmap data="1"/></o:shapelayout><v:shapetype id="_x0000_t202" path="m,l,21600r21600,l21600,xe"><v:path gradientshapeok="t"/></v:shapetype></xml>"#;
|
||||
let source = with_rels(
|
||||
&plain(),
|
||||
&[(WB_RELS, MADE_UP), (SHEET_RELS, rels)],
|
||||
&[MADE_UP_PART, ("xl/comments1.xml", comments), ("xl/drawings/vmlDrawing1.vml", vml)],
|
||||
);
|
||||
assert_eq!(package_of(&source), ["lost relationship madeUp"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_drawings_and_person_lists_are_not_a_loss() {
|
||||
let drawing = r#"<Relationship Id="rId8" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" Target="../drawings/drawing1.xml"/>"#;
|
||||
let person =
|
||||
r#"<Relationship Id="rId92" Type="http://schemas.microsoft.com/office/2017/10/relationships/person" Target="persons/person.xml"/>"#;
|
||||
let source = with_rels(
|
||||
&plain(),
|
||||
&[(WB_RELS, &format!("{MADE_UP}{person}")), (SHEET_RELS, drawing)],
|
||||
&[
|
||||
MADE_UP_PART,
|
||||
("xl/drawings/drawing1.xml", r#"<xdr:wsDr xmlns:xdr="http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing"/>"#),
|
||||
("xl/persons/person.xml", r#"<personList xmlns="http://schemas.microsoft.com/office/spreadsheetml/2018/threadedcomments"/>"#),
|
||||
],
|
||||
);
|
||||
assert_eq!(package_of(&source), ["lost relationship madeUp"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unopenable_files_are_reported() {
|
||||
let r = check_bytes("broken.xlsx", b"PK\x03\x04 not really", &Options::default());
|
||||
@@ -1362,7 +1620,19 @@ mod tests {
|
||||
assert_eq!(keys(f), set(&["path", "opened", "error", "warnings", "calc", "roundTrip"]));
|
||||
assert_eq!(
|
||||
keys(&f["calc"]),
|
||||
set(&["formulaCells", "checked", "matched", "mismatched", "noOracle", "staleCache", "volatile", "mismatches", "functions", "signatures"])
|
||||
set(&[
|
||||
"formulaCells",
|
||||
"checked",
|
||||
"matched",
|
||||
"mismatched",
|
||||
"noOracle",
|
||||
"staleCache",
|
||||
"volatile",
|
||||
"boolAsNumber",
|
||||
"mismatches",
|
||||
"functions",
|
||||
"signatures"
|
||||
])
|
||||
);
|
||||
assert_eq!(f["calc"]["signatures"], json!({"number → number": 1}));
|
||||
assert_eq!(keys(&f["calc"]["mismatches"][0]), set(&["cell", "formula", "expected", "actual", "kind", "functions", "primary"]));
|
||||
@@ -1401,6 +1671,7 @@ mod tests {
|
||||
"mismatched",
|
||||
"noOracle",
|
||||
"volatile",
|
||||
"boolAsNumber",
|
||||
"roundTripFailed",
|
||||
"roundTripDiffering",
|
||||
"categoriesDiffering"
|
||||
|
||||
@@ -277,7 +277,7 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
let mut s = open(input)?;
|
||||
activate_sheet(&mut s, a.opt("sheet"))?;
|
||||
let d = s.doc().map_err(|e| e.to_string())?;
|
||||
let bytes = gridcraft_engine::io::save_bytes(&d.wb, out).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())?;
|
||||
eprintln!("wrote {out} ({} bytes)", bytes.len());
|
||||
Ok(())
|
||||
|
||||
@@ -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"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "HtmlIFrameElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Clipboard", "ClipboardItem", "Navigator", "MessageEvent", "UrlSearchParams"] }
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
|
||||
//! GridCraft in an `<iframe>` (such as a Nextcloud app). Messages are plain objects with a `type`:
|
||||
//!
|
||||
//! - `gridcraft:ready` (to the parent): `{ version }`, the app is listening; send a file now.
|
||||
//! - `gridcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
|
||||
//! `Uint8Array`. It opens like a workbook picked with File › Open or dropped on the window.
|
||||
//! - `gridcraft:save` (to the parent): `{ name, bytes }` (a transferred `ArrayBuffer`) for every
|
||||
//! file GridCraft would otherwise download: Save and Save As (an .xlsx workbook) and Export
|
||||
//! as PDF. The parent stores it and reports failures itself.
|
||||
//! - `gridcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
|
||||
//! - `gridcraft:failed` (to the parent): `{ error }` when the app couldn't start.
|
||||
//!
|
||||
//! Only the parent window on the page's own origin is heard or answered. In host mode the page
|
||||
//! starts with an empty workbook instead of a sample, and the parent guards leaving the page.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
use gridcraft_ui_egui::{Inbox, Services, SheetApp};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wasm_bindgen::JsValue;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
|
||||
/// Saving one of these hands the whole workbook to the parent, which stores it: the workbook
|
||||
/// counts as saved under that name, as it would after Save on the desktop.
|
||||
const WORKBOOK_EXTS: &[&str] = &["xlsx"];
|
||||
|
||||
/// The longest user name taken from `?author=`.
|
||||
const MAX_AUTHOR_CHARS: usize = 200;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Bridge {
|
||||
window: web_sys::Window,
|
||||
parent: web_sys::Window,
|
||||
origin: String,
|
||||
/// Names of the files handed to the parent since the last frame.
|
||||
saved: Rc<RefCell<Vec<String>>>,
|
||||
/// The unsaved-changes state last reported to the parent.
|
||||
reported: Rc<Cell<Option<bool>>>,
|
||||
}
|
||||
|
||||
impl Bridge {
|
||||
/// `None` when the page isn't framed (there is no parent to talk to).
|
||||
pub fn new() -> Option<Self> {
|
||||
let window = web_sys::window()?;
|
||||
let parent = window.parent().ok().flatten()?;
|
||||
if js_sys::Object::is(&parent, &window) {
|
||||
return None;
|
||||
}
|
||||
let origin = window.location().origin().ok()?;
|
||||
Some(Self { window, parent, origin, saved: Rc::default(), reported: Rc::default() })
|
||||
}
|
||||
|
||||
/// Send saved and exported files to the parent instead of downloading them, and hear files
|
||||
/// from it.
|
||||
pub fn connect(&self, services: &mut Services, inbox: Inbox, ctx: egui::Context) {
|
||||
self.listen(inbox, ctx);
|
||||
let bridge = self.clone();
|
||||
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| {
|
||||
if let Err(e) = bridge.post_save(name, bytes) {
|
||||
log::error!("couldn't hand {name} to the page: {e}");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// `?author=`: the host's name for the user signs new notes and comments.
|
||||
pub fn set_author(app: &mut SheetApp, author: &str) {
|
||||
let author: String = author.trim().chars().take(MAX_AUTHOR_CHARS).collect();
|
||||
if !author.is_empty() {
|
||||
app.session.prefs.user_name = author;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn post_ready(&self) {
|
||||
self.send(&[("type", "gridcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
|
||||
}
|
||||
|
||||
pub fn post_failed(&self, error: &str) {
|
||||
self.send(&[("type", "gridcraft:failed".into()), ("error", error.into())]);
|
||||
}
|
||||
|
||||
/// After each frame: a workbook handed to the parent this frame counts as saved under the
|
||||
/// name it was saved as, and the parent hears when unsaved changes appear or go away.
|
||||
pub fn sync(&self, app: &mut SheetApp) {
|
||||
let saved = std::mem::take(&mut *self.saved.borrow_mut());
|
||||
if let Some(name) = saved.iter().rev().find(|name| is_workbook(name))
|
||||
&& let Some(doc) = app.session.active_mut()
|
||||
{
|
||||
doc.saved = doc.wb.clone();
|
||||
if doc.path.is_none() {
|
||||
doc.title = name.clone();
|
||||
}
|
||||
}
|
||||
let dirty = app.session.documents().iter().any(|d| d.is_dirty());
|
||||
if self.reported.get() != Some(dirty) && self.send(&[("type", "gridcraft:dirty".into()), ("dirty", dirty.into())]) {
|
||||
self.reported.set(Some(dirty));
|
||||
}
|
||||
}
|
||||
|
||||
fn post_save(&self, path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
|
||||
let buffer = js_sys::Uint8Array::from(bytes).buffer();
|
||||
let message = message(&[("type", "gridcraft:save".into()), ("name", name.as_str().into()), ("bytes", buffer.clone().into())])
|
||||
.ok_or_else(|| "couldn't build the message".to_string())?;
|
||||
self.parent
|
||||
.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
|
||||
.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))?;
|
||||
self.saved.borrow_mut().push(name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn listen(&self, inbox: Inbox, ctx: egui::Context) {
|
||||
let parent = self.parent.clone();
|
||||
let origin = self.origin.clone();
|
||||
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
|
||||
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
|
||||
return;
|
||||
}
|
||||
let data = event.data();
|
||||
if string(&data, "type").as_deref() != Some("gridcraft:open") {
|
||||
return;
|
||||
}
|
||||
let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "Book.xlsx".to_string());
|
||||
let Some(bytes) = bytes(&data) else { return };
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
|
||||
// The listener lives as long as the page.
|
||||
on_message.forget();
|
||||
}
|
||||
}
|
||||
|
||||
/// Post a message to the parent; false if it couldn't be sent.
|
||||
fn send(&self, fields: &[(&str, JsValue)]) -> bool {
|
||||
message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
|
||||
}
|
||||
}
|
||||
|
||||
fn is_workbook(name: &str) -> bool {
|
||||
name.rsplit_once('.').is_some_and(|(_, ext)| WORKBOOK_EXTS.iter().any(|e| ext.eq_ignore_ascii_case(e)))
|
||||
}
|
||||
|
||||
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
|
||||
let m = js_sys::Object::new();
|
||||
for (key, value) in fields {
|
||||
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
|
||||
}
|
||||
Some(m)
|
||||
}
|
||||
|
||||
fn string(data: &JsValue, key: &str) -> Option<String> {
|
||||
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
|
||||
}
|
||||
|
||||
/// `bytes` as an `ArrayBuffer` or `Uint8Array`.
|
||||
fn bytes(data: &JsValue) -> Option<Vec<u8>> {
|
||||
let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
|
||||
if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
|
||||
Some(array.to_vec())
|
||||
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
|
||||
Some(js_sys::Uint8Array::new(&bytes).to_vec())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,14 @@
|
||||
//! (bytes arrive asynchronously through `Services::inbox`); Save downloads the file; dropped
|
||||
//! files are read asynchronously.
|
||||
//!
|
||||
//! URL query flags: `?webgl` forces WebGL2; `?sample=sales|budget|grades` opens a sample.
|
||||
//! 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.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod host;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
|
||||
@@ -25,6 +25,10 @@ pub fn start() {
|
||||
{
|
||||
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
|
||||
}
|
||||
// `?host=parent`: a same-origin page framing GridCraft (such as a Nextcloud app) opens
|
||||
// workbooks in it and stores what it saves (`host`).
|
||||
let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };
|
||||
let app_bridge = bridge.clone();
|
||||
let result = eframe::WebRunner::new()
|
||||
.start(
|
||||
canvas,
|
||||
@@ -32,13 +36,18 @@ pub fn start() {
|
||||
Box::new(move |cc| {
|
||||
let inbox: Inbox = Inbox::default();
|
||||
let mut session = Session::new();
|
||||
let q = query();
|
||||
// In host mode the parent sends the workbook to edit; no sample meanwhile.
|
||||
let q = if app_bridge.is_some() { String::new() } else { query() };
|
||||
if let Some(name) = q.split(['?', '&']).find_map(|kv| kv.strip_prefix("sample=")) {
|
||||
let _ = session.execute("file.new", serde_json::json!({"sample": name}));
|
||||
} else if q.contains("sample") {
|
||||
let _ = session.execute("file.new", serde_json::json!({"sample": "sales"}));
|
||||
}
|
||||
let mut app = SheetApp::new(session, services(inbox.clone(), cc.egui_ctx.clone()));
|
||||
let mut services = services(inbox.clone(), cc.egui_ctx.clone());
|
||||
if let Some(bridge) = &app_bridge {
|
||||
bridge.connect(&mut services, inbox.clone(), cc.egui_ctx.clone());
|
||||
}
|
||||
let mut app = SheetApp::new(session, services);
|
||||
app.ui.system_theme = true;
|
||||
// Follow the browser's language, except a CJK one: the web build has no system
|
||||
// CJK fonts (none are bundled), so that interface would render as tofu.
|
||||
@@ -50,10 +59,19 @@ pub fn start() {
|
||||
app.ui.language = browser;
|
||||
}
|
||||
SheetApp::setup_context_for_language(&cc.egui_ctx, false, app.ui.language);
|
||||
Ok(Box::new(WebShell { app, inbox }))
|
||||
if let Some(bridge) = &app_bridge {
|
||||
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 }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
if let (Err(e), Some(bridge)) = (&result, &bridge) {
|
||||
bridge.post_failed(&format!("{e:?}"));
|
||||
}
|
||||
if let Some(el) = document.get_element_by_id(LOADING_ID) {
|
||||
match result {
|
||||
Ok(()) => el.remove(),
|
||||
@@ -67,9 +85,14 @@ fn query() -> String {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn query_param(key: &str) -> Option<String> {
|
||||
web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)
|
||||
}
|
||||
|
||||
struct WebShell {
|
||||
app: SheetApp,
|
||||
inbox: Inbox,
|
||||
bridge: Option<crate::host::Bridge>,
|
||||
}
|
||||
|
||||
impl eframe::App for WebShell {
|
||||
@@ -98,6 +121,9 @@ impl eframe::App for WebShell {
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.app.ui(ui);
|
||||
if let Some(bridge) = &self.bridge {
|
||||
bridge.sync(&mut self.app);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,8 +170,10 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
}
|
||||
})),
|
||||
open_url: Some(Box::new(|url: &str| {
|
||||
if let Some(w) = web_sys::window() {
|
||||
let _ = w.open_with_url_and_target(url, "_blank");
|
||||
if let Some(w) = web_sys::window()
|
||||
&& let gridcraft_engine::links::LinkKind::External(url) = gridcraft_engine::links::classify(url)
|
||||
{
|
||||
let _ = w.open_with_url_and_target(&url, "_blank");
|
||||
}
|
||||
})),
|
||||
inbox: Some(inbox),
|
||||
|
||||
@@ -42,3 +42,13 @@ muda = { version = "0.21", default-features = false }
|
||||
# by PdfCraft and PhotoCraft) registers the handler (Objective-C interop needs unsafe, which our
|
||||
# workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping.
|
||||
fmv-macos-events = "=0.1.0"
|
||||
|
||||
# tests/mac_menu_appkit.rs: reads AppKit's menu bar back. Already in Cargo.lock (muda, winit);
|
||||
# pinned exactly to the same versions.
|
||||
[target.'cfg(target_os = "macos")'.dev-dependencies]
|
||||
objc2 = "=0.6.5"
|
||||
objc2-app-kit = { version = "=0.3.2", default-features = false, features = ["std", "NSApplication", "NSMenu", "NSMenuItem", "NSResponder"] }
|
||||
|
||||
[[test]]
|
||||
name = "mac_menu_appkit"
|
||||
harness = false
|
||||
|
||||
@@ -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,184 @@ 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);
|
||||
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(|_| 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);
|
||||
}
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,10 +135,22 @@ fn save_prefs(app: &SheetApp) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the system clipboard through the shared handle, creating it on first use.
|
||||
fn read_clipboard<T>(
|
||||
handle: &RefCell<Option<arboard::Clipboard>>,
|
||||
read: impl FnOnce(&mut arboard::Clipboard) -> Result<T, arboard::Error>,
|
||||
) -> Option<T> {
|
||||
let mut clipboard = handle.try_borrow_mut().ok()?;
|
||||
if clipboard.is_none() {
|
||||
*clipboard = arboard::Clipboard::new().ok();
|
||||
}
|
||||
read(clipboard.as_mut()?).ok()
|
||||
}
|
||||
|
||||
fn services() -> Services {
|
||||
// Keep the owner alive: X11/Wayland serve clipboard data from this handle.
|
||||
let clipboard: Rc<RefCell<Option<arboard::Clipboard>>> = Rc::default();
|
||||
let paste_clipboard = clipboard.clone();
|
||||
let (html_clipboard, text_clipboard) = (clipboard.clone(), clipboard.clone());
|
||||
Services {
|
||||
copy_html: Some(Box::new(move |html, text| {
|
||||
let mut clipboard = clipboard.borrow_mut();
|
||||
@@ -147,13 +159,8 @@ fn services() -> Services {
|
||||
}
|
||||
clipboard.as_mut().ok_or("clipboard unavailable")?.set_html(html, Some(text)).map_err(|e| e.to_string())
|
||||
})),
|
||||
paste_html: Some(Box::new(move || {
|
||||
let mut clipboard = paste_clipboard.try_borrow_mut().ok()?;
|
||||
if clipboard.is_none() {
|
||||
*clipboard = arboard::Clipboard::new().ok();
|
||||
}
|
||||
clipboard.as_mut()?.get().html().ok()
|
||||
})),
|
||||
paste_html: Some(Box::new(move || read_clipboard(&html_clipboard, |clipboard| clipboard.get().html()))),
|
||||
paste_text: Some(Box::new(move || read_clipboard(&text_clipboard, |clipboard| clipboard.get_text()))),
|
||||
pick_open: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter("Spreadsheets", &["xlsx", "xlsm", "xls", "xlsb", "ods", "csv", "tsv", "txt", "json"])
|
||||
@@ -194,6 +201,13 @@ fn services() -> Services {
|
||||
|
||||
/// Opens a URL or file with the system's default application.
|
||||
fn open_with_system(target: &str) {
|
||||
// Last line of defence behind `link.open` and the click prompt: the system only ever gets
|
||||
// http, https and mailto addresses.
|
||||
let gridcraft_engine::links::LinkKind::External(target) = gridcraft_engine::links::classify(target) else {
|
||||
log::warn!("refused to open {target:?}: only http, https and mailto links are opened");
|
||||
return;
|
||||
};
|
||||
let target = target.as_str();
|
||||
let cmd = if cfg!(target_os = "macos") {
|
||||
"open"
|
||||
} else if cfg!(windows) {
|
||||
@@ -369,3 +383,32 @@ fn main() -> eframe::Result<()> {
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod packaging_tests {
|
||||
use gridcraft_engine::io::FileKind;
|
||||
|
||||
/// The text between `start` and `end` that follows each `marker` in `text`, sorted (e.g. the
|
||||
/// `xlsb` in `Classes\.xlsb\OpenWithProgids`).
|
||||
fn extensions(text: &str, marker: &str, start: &str, end: char) -> Vec<String> {
|
||||
let mut v: Vec<String> = text.split(marker).skip(1).filter_map(|s| s.split_once(start)?.1.split(end).next()).map(str::to_string).collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
/// macOS Open With (`CFBundleDocumentTypes`) offers GridCraft for the same files as Windows
|
||||
/// Open with, and GridCraft opens each of them. #321 added `.xlsb` and `.ods` on Windows and
|
||||
/// Linux only.
|
||||
#[test]
|
||||
fn macos_and_windows_offer_the_same_file_types() {
|
||||
let plist = include_str!("../../../packaging/macos/Info.plist.in");
|
||||
let wxs = include_str!("../../../packaging/windows/gridcraft.wxs");
|
||||
let mac = extensions(plist, "<key>CFBundleTypeExtensions</key>", "<string>", '<');
|
||||
let win = extensions(wxs, "Key=\"Software\\Classes\\.", "", '\\');
|
||||
assert!(mac.iter().any(|e| e == "xlsx"), "Info.plist.in extensions not found: {mac:?}");
|
||||
assert_eq!(mac, win);
|
||||
for ext in &mac {
|
||||
assert!(FileKind::from_path(&format!("book.{ext}")).is_some(), "GridCraft doesn't open .{ext}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ fn tree() -> Vec<(&'static str, Vec<Entry>)> {
|
||||
("Close", "file.close", Some("CMD+W")),
|
||||
("Save", "file.save", Some("CMD+S")),
|
||||
("Save As…", "file.saveAs", Some("CMD+SHIFT+S")),
|
||||
("Export as PDF…", "file.exportPdf", None),
|
||||
("Export as CSV…", "dialog:saveCopy:csv", None),
|
||||
("Save as Web Page…", "dialog:saveCopy:html", None),
|
||||
("-", "", None),
|
||||
@@ -215,6 +216,7 @@ impl NativeMenu {
|
||||
]);
|
||||
let _ = menu.append(&app_menu);
|
||||
let mut theme_items = Vec::new();
|
||||
let mut window_menu = None;
|
||||
for (title, entries) in tree() {
|
||||
let sub = Submenu::new(title, true);
|
||||
for (label, id, acc) in entries {
|
||||
@@ -237,8 +239,17 @@ impl NativeMenu {
|
||||
let _ = sub.append(&item);
|
||||
}
|
||||
let _ = menu.append(&sub);
|
||||
if title == "Window" {
|
||||
window_menu = Some(sub);
|
||||
}
|
||||
}
|
||||
menu.init_for_nsapp();
|
||||
// NSApp's windowsMenu is where macOS adds Move & Resize, Fill and Center, the targets of the
|
||||
// system tiling shortcuts (Fn+Ctrl+arrows, Fn+F). Only once the menu is NSApp's: muda looks
|
||||
// the submenu up in the installed main menu.
|
||||
if let Some(window) = window_menu {
|
||||
window.set_as_windows_menu_for_nsapp();
|
||||
}
|
||||
let (tx, rx) = channel();
|
||||
let ctx = ctx.clone();
|
||||
MenuEvent::set_event_handler(Some(move |e: MenuEvent| {
|
||||
@@ -298,6 +309,8 @@ impl NativeMenu {
|
||||
app.open_dialog(name, params);
|
||||
} else if id == "edit.copy" || id == "edit.cut" {
|
||||
app.copy_to_clipboard(ctx, &id);
|
||||
} else if id == "edit.paste" {
|
||||
app.paste_from_clipboard();
|
||||
} else {
|
||||
app.run_or_alert(&id, json!({}));
|
||||
}
|
||||
@@ -315,6 +328,15 @@ impl NativeMenu {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn file_menu_has_export_as_pdf_dispatching_the_command() {
|
||||
let tree = tree();
|
||||
let (_, file) = tree.iter().find(|(title, _)| *title == "File").expect("File menu");
|
||||
let item = file.iter().find(|e| e.0 == "Export as PDF…").expect("Export as PDF… in the File menu");
|
||||
assert_eq!(item.1, "file.exportPdf");
|
||||
assert!(gridcraft_engine::find_command("file.exportPdf").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_choices_use_the_shared_preference_command() {
|
||||
let mut app = SheetApp::new(gridcraft_engine::Session::new(), Default::default());
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
//! The macOS menu bar as AppKit sees it, on the main thread (`harness = false`): install it through
|
||||
//! the real muda backend (`src/native_menu.rs`) and read NSApp back. The Window menu must be NSApp's
|
||||
//! `windowsMenu`: that is where macOS adds Move & Resize, Fill and Center, the targets of the system
|
||||
//! window tiling shortcuts (Fn+Ctrl+arrows, Fn+F), and lists the open windows (#716).
|
||||
|
||||
// The app's own module, built into this test (cargo builds harness = false tests with cfg(test), so its
|
||||
// unit tests compile here too without a harness to run them).
|
||||
#[cfg(target_os = "macos")]
|
||||
#[path = "../src/native_menu.rs"]
|
||||
#[allow(unused_imports, dead_code)]
|
||||
mod native_menu;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn main() {
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2_app_kit::NSApplication;
|
||||
|
||||
let mtm = MainThreadMarker::new().expect("harness = false runs on the main thread");
|
||||
let nsapp = NSApplication::sharedApplication(mtm);
|
||||
nsapp.finishLaunching();
|
||||
|
||||
let ctx = egui::Context::default();
|
||||
let _menu = native_menu::NativeMenu::install(&ctx);
|
||||
|
||||
let bar = nsapp.mainMenu().expect("a main menu");
|
||||
let tops: Vec<String> = (0..bar.numberOfItems()).filter_map(|i| bar.itemAtIndex(i)?.submenu()).map(|m| m.title().to_string()).collect();
|
||||
assert_eq!(tops, ["GridCraft", "File", "Edit", "View", "Insert", "Format", "Tools", "Data", "Window", "Help"]);
|
||||
assert_eq!(nsapp.windowsMenu().map(|m| m.title().to_string()).as_deref(), Some("Window"), "AppKit knows the Window menu");
|
||||
|
||||
println!("mac_menu_appkit: ok");
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn main() {}
|
||||
@@ -61,3 +61,10 @@ sync_github_name = true
|
||||
real_name = "James Symes"
|
||||
sync_github_name = true
|
||||
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
|
||||
[people.Shnrqpdr]
|
||||
sync_github_name = true
|
||||
|
||||
+157
-52
@@ -88,6 +88,8 @@ pub struct Evaluator<'h> {
|
||||
|
||||
struct FnCtx<'a, 'h> {
|
||||
ev: &'a mut Evaluator<'h>,
|
||||
/// The criteria argument of a database function, when it is a reference.
|
||||
criteria: Option<Area>,
|
||||
}
|
||||
|
||||
impl Ctx for FnCtx<'_, '_> {
|
||||
@@ -100,6 +102,28 @@ impl Ctx for FnCtx<'_, '_> {
|
||||
fn random(&mut self) -> f64 {
|
||||
self.ev.host.random()
|
||||
}
|
||||
/// Excel's "formula as criteria": the formula is written for the first record, so its relative
|
||||
/// references move down to `record`'s row, and the database's labels name its fields.
|
||||
/// Known gap: the shifted references are not dependencies of the calling cell. One that lands
|
||||
/// outside both the database and the criteria cell's own precedents (say `=G2<H2` with column H
|
||||
/// outside the database) is read here but not tracked, so editing that cell doesn't recalculate
|
||||
/// the D-function until something else does.
|
||||
fn computed_criterion(&mut self, row: usize, col: usize, db: &Array, record: usize) -> Option<bool> {
|
||||
let area = self.criteria?;
|
||||
let at = area.range.start.offset(row as i64, col as i64).filter(|c| area.range.contains(*c))?;
|
||||
let expr = self.ev.host.workbook().sheet(area.sheet)?.cell(at)?.formula.as_ref()?.expr_arc()?;
|
||||
let shifted = gridcraft_formula::adjust::shift_relative((*expr).clone(), record as i64 - 1, 0);
|
||||
let env = (0..db.cols)
|
||||
.filter_map(|c| match db.get(0, c) {
|
||||
Some(Value::Text(t)) if !t.trim().is_empty() => {
|
||||
Some((t.trim().to_string(), Ev::V(db.get(record, c).cloned().unwrap_or(Value::Empty))))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
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)))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'h> Evaluator<'h> {
|
||||
@@ -383,6 +407,7 @@ impl<'h> Evaluator<'h> {
|
||||
};
|
||||
}
|
||||
let v = self.value(x);
|
||||
let sys = self.host.workbook().date_system;
|
||||
Ev::V(map_unary(
|
||||
&v,
|
||||
&|n| match op {
|
||||
@@ -392,6 +417,7 @@ impl<'h> Evaluator<'h> {
|
||||
UnOp::At | UnOp::Spill | UnOp::Trim(_) => Value::number(n),
|
||||
},
|
||||
op == UnOp::Plus,
|
||||
sys,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -465,9 +491,22 @@ impl<'h> Evaluator<'h> {
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let x = self.value(a);
|
||||
let y = self.value(b);
|
||||
Ev::V(binary_values(op, &x, &y))
|
||||
// A long chain such as A1+A2+…+A500 nests to the left: fold it in a loop instead
|
||||
// of one level of recursion per operator (past the depth limit it was #CALC!).
|
||||
let mut rest = vec![(op, b)];
|
||||
let mut left = a;
|
||||
while let Expr::Binary(o, l, r) = left
|
||||
&& !matches!(o, BinOp::Range | BinOp::Union | BinOp::Intersect)
|
||||
{
|
||||
rest.push((*o, r.as_ref()));
|
||||
left = l.as_ref();
|
||||
}
|
||||
let mut acc = self.value(left);
|
||||
while let Some((o, r)) = rest.pop() {
|
||||
let y = self.value(r);
|
||||
acc = binary_values(o, &acc, &y, self.host.workbook().date_system);
|
||||
}
|
||||
Ev::V(acc)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -579,7 +618,7 @@ impl<'h> Evaluator<'h> {
|
||||
let value = self.value(a);
|
||||
evaluated.push(Arg { value, from_ref: false });
|
||||
}
|
||||
let mut ctx = FnCtx { ev: self };
|
||||
let mut ctx = FnCtx { ev: self, criteria: None };
|
||||
return gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V);
|
||||
}
|
||||
let i = match index.to_number() {
|
||||
@@ -737,6 +776,20 @@ impl<'h> Evaluator<'h> {
|
||||
Ev::V(Value::Bool(r))
|
||||
}
|
||||
"MAP" | "REDUCE" | "SCAN" | "BYROW" | "BYCOL" | "MAKEARRAY" => self.higher_order(name, args),
|
||||
"GROUPBY" | "PIVOTBY" => {
|
||||
// The aggregation may be a function given by name (`SUM`): pass it as its name.
|
||||
let i = if name == "GROUPBY" { 2 } else { 3 };
|
||||
match args.get(i).and_then(|a| self.function_by_name(a)) {
|
||||
Some(f) => {
|
||||
let mut args = args.to_vec();
|
||||
if let Some(a) = args.get_mut(i) {
|
||||
*a = Expr::Text(f.into());
|
||||
}
|
||||
self.builtin(name, &args)
|
||||
}
|
||||
None => self.builtin(name, args),
|
||||
}
|
||||
}
|
||||
"NOW" | "TODAY" | "RAND" | "RANDBETWEEN" | "RANDARRAY" => {
|
||||
self.host.note_volatile();
|
||||
self.builtin(name, args)
|
||||
@@ -762,8 +815,17 @@ impl<'h> Evaluator<'h> {
|
||||
let mut evaluated = Vec::with_capacity(args.len());
|
||||
// SUMIF/AVERAGEIF: the size of the criteria range (argument 1), which the sum range takes.
|
||||
let mut criteria_shape = None;
|
||||
// Database functions: the criteria range (argument 2), whose formulas are computed criteria.
|
||||
let mut criteria = None;
|
||||
for (i, a) in args.iter().enumerate() {
|
||||
let mut ev = self.eval(a);
|
||||
if spec.category == gridcraft_functions::Category::Database
|
||||
&& i == 2
|
||||
&& let Ev::R(areas) = &ev
|
||||
&& let [area] = areas.as_slice()
|
||||
{
|
||||
criteria = Some(*area);
|
||||
}
|
||||
if resizes_sum_range(name, args.len()) {
|
||||
if i == 0 {
|
||||
criteria_shape = ev_shape(&ev);
|
||||
@@ -791,7 +853,7 @@ impl<'h> Evaluator<'h> {
|
||||
};
|
||||
evaluated.push(Arg { value, from_ref });
|
||||
}
|
||||
let mut ctx = FnCtx { ev: self };
|
||||
let mut ctx = FnCtx { ev: self, criteria };
|
||||
gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V)
|
||||
}
|
||||
|
||||
@@ -845,13 +907,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 }) {
|
||||
match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self, criteria: 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 }) {
|
||||
match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self, criteria: None }) {
|
||||
Value::Number(n) => acc.count = acc.count.saturating_add(n as u64),
|
||||
other => return Err(Ev::V(other)),
|
||||
}
|
||||
@@ -860,7 +922,12 @@ impl<'h> Evaluator<'h> {
|
||||
}
|
||||
|
||||
pub fn apply_lambda(&mut self, l: &Lambda, args: &[Expr]) -> Ev {
|
||||
if args.len() > l.params.len() {
|
||||
// Too many or too few argument positions is #VALUE!. An explicitly empty position, as in
|
||||
// `(1,)`, is `Expr::Missing` and still counts as a position (it binds as omitted). `f()`
|
||||
// parses to no arguments, which for a one-parameter lambda can also be read as one empty
|
||||
// position, so that call still binds the parameter as omitted.
|
||||
let positions_ok = args.len() == l.params.len() || (args.is_empty() && l.params.len() == 1);
|
||||
if !positions_ok {
|
||||
return err(CellError::Value);
|
||||
}
|
||||
let mut bound = Vec::with_capacity(l.params.len());
|
||||
@@ -903,7 +970,26 @@ impl<'h> Evaluator<'h> {
|
||||
r
|
||||
}
|
||||
|
||||
fn lambda_arg(&mut self, e: Option<&Expr>) -> Option<Arc<Lambda>> {
|
||||
/// A worksheet function given by name where a function is expected (`BYROW(A1:C3,SUM)`,
|
||||
/// `GROUPBY(A2:A9,B2:B9,SUM)`): its name, unless a LET/LAMBDA variable or a defined name has
|
||||
/// 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() {
|
||||
return None;
|
||||
}
|
||||
let u = n.to_ascii_uppercase();
|
||||
gridcraft_functions::lookup(&u).map(|_| u)
|
||||
}
|
||||
|
||||
/// The function argument of a LAMBDA helper; a function given by name takes `arity`
|
||||
/// arguments.
|
||||
fn lambda_arg(&mut self, e: Option<&Expr>, arity: usize) -> Option<Arc<Lambda>> {
|
||||
if let Some(f) = e.and_then(|x| self.function_by_name(x)) {
|
||||
let params: Vec<String> = (0..arity).map(|i| format!("_xleta.{i}")).collect();
|
||||
let body = Expr::Call(f, params.iter().map(|p| Expr::Name(p.clone())).collect());
|
||||
return Some(Arc::new(Lambda { params, body, env: Vec::new() }));
|
||||
}
|
||||
match e.map(|x| self.eval(x)) {
|
||||
Some(Ev::L(l)) => Some(l),
|
||||
_ => None,
|
||||
@@ -922,7 +1008,7 @@ impl<'h> Evaluator<'h> {
|
||||
if args.len() < 2 {
|
||||
return err(CellError::Value);
|
||||
}
|
||||
let Some(l) = self.lambda_arg(args.last()) else { return err(CellError::Value) };
|
||||
let Some(l) = self.lambda_arg(args.last(), args.len() - 1) else { return err(CellError::Value) };
|
||||
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);
|
||||
@@ -945,7 +1031,7 @@ impl<'h> Evaluator<'h> {
|
||||
}
|
||||
let mut acc = self.value(&args[0]);
|
||||
let arr = as_array(self.value(&args[1]));
|
||||
let Some(l) = self.lambda_arg(args.get(2)) else { return err(CellError::Value) };
|
||||
let Some(l) = self.lambda_arg(args.get(2), 2) else { return err(CellError::Value) };
|
||||
if l.params.len() != 2 {
|
||||
return err(CellError::Value);
|
||||
}
|
||||
@@ -969,7 +1055,7 @@ impl<'h> Evaluator<'h> {
|
||||
return err(CellError::Value);
|
||||
}
|
||||
let arr = as_array(self.value(&args[0]));
|
||||
let Some(l) = self.lambda_arg(args.get(1)) else { return err(CellError::Value) };
|
||||
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) };
|
||||
let by_row = name == "BYROW";
|
||||
let n = if by_row { arr.rows } else { arr.cols };
|
||||
@@ -998,7 +1084,7 @@ impl<'h> Evaluator<'h> {
|
||||
if rows < 1.0 || cols < 1.0 || rows * cols > MAX_CELLS as f64 {
|
||||
return err(CellError::Value);
|
||||
}
|
||||
let Some(l) = self.lambda_arg(args.get(2)) else { return err(CellError::Value) };
|
||||
let Some(l) = self.lambda_arg(args.get(2), 2) else { return err(CellError::Value) };
|
||||
if l.params.len() != 2 {
|
||||
return err(CellError::Value);
|
||||
}
|
||||
@@ -1054,10 +1140,10 @@ impl<'h> Evaluator<'h> {
|
||||
if h == 0 || w == 0 {
|
||||
return err(CellError::Ref);
|
||||
}
|
||||
let r0 = base.range.start.row as i64 + rows;
|
||||
let c0 = base.range.start.col as i64 + cols;
|
||||
let (r0, r1) = if h > 0 { (r0, r0 + h - 1) } else { (r0 + h + 1, r0) };
|
||||
let (c0, c1) = if w > 0 { (c0, c0 + w - 1) } else { (c0 + w + 1, c0) };
|
||||
let r0 = (base.range.start.row as i64).saturating_add(rows);
|
||||
let c0 = (base.range.start.col as i64).saturating_add(cols);
|
||||
let (r0, r1) = if h > 0 { (r0, r0.saturating_add(h - 1)) } else { (r0.saturating_add(h + 1), r0) };
|
||||
let (c0, c1) = if w > 0 { (c0, c0.saturating_add(w - 1)) } else { (c0.saturating_add(w + 1), c0) };
|
||||
if r0 < 0 || c0 < 0 || r1 >= MAX_ROWS as i64 || c1 >= MAX_COLS as i64 {
|
||||
return err(CellError::Ref);
|
||||
}
|
||||
@@ -1081,8 +1167,18 @@ impl<'h> Evaluator<'h> {
|
||||
v.scalar().to_bool().unwrap_or(true)
|
||||
}
|
||||
};
|
||||
let text = if a1 { text } else { r1c1_to_a1(&text, self.at).unwrap_or(text) };
|
||||
// With a1 = FALSE, text that isn't R1C1 can still be a name, but never an A1 reference.
|
||||
let (text, a1_ok) = if a1 {
|
||||
(text, true)
|
||||
} else {
|
||||
match r1c1_to_a1(&text, self.at) {
|
||||
Ok(Some(a1_text)) => (a1_text, true),
|
||||
Ok(None) => (text, false),
|
||||
Err(e) => return err(e),
|
||||
}
|
||||
};
|
||||
match gridcraft_formula::parse(&text) {
|
||||
Ok(Expr::Ref(_)) if !a1_ok => err(CellError::Ref),
|
||||
Ok(e @ (Expr::Ref(_) | Expr::Name(_) | Expr::Struct(_))) => match self.eval(&e) {
|
||||
r @ Ev::R(_) => r,
|
||||
_ => err(CellError::Ref),
|
||||
@@ -1152,7 +1248,8 @@ impl<'h> Evaluator<'h> {
|
||||
}
|
||||
match src {
|
||||
Ev::R(areas) => {
|
||||
let Some(a) = areas.get((area_n - 1).max(0) as usize).copied() else { return err(CellError::Ref) };
|
||||
let area = usize::try_from((area_n as u64).saturating_sub(1)).ok();
|
||||
let Some(a) = area.and_then(|i| areas.get(i)).copied() else { return err(CellError::Ref) };
|
||||
let r = a.range;
|
||||
// A single row or column with one index picks along it.
|
||||
let (row, col) = if two_args && r.height() == 1 { (1, row) } else { (row, col) };
|
||||
@@ -1221,7 +1318,7 @@ impl<'h> Evaluator<'h> {
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut ctx = FnCtx { ev: self };
|
||||
let mut ctx = FnCtx { ev: self, criteria: None };
|
||||
return match gridcraft_functions::call(spec, &xargs, &mut ctx) {
|
||||
Value::Number(p) if p >= 1.0 => {
|
||||
let k = p as u32 - 1;
|
||||
@@ -1275,7 +1372,7 @@ impl<'h> Evaluator<'h> {
|
||||
_ => "v",
|
||||
}))
|
||||
}
|
||||
"width" => Ev::V(Value::number(sheet.map_or(8.43, |s| (s.col_width(c.col) as f64 / 7.0 * 100.0).round() / 100.0).round())),
|
||||
"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())),
|
||||
"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 })),
|
||||
@@ -1290,7 +1387,9 @@ impl<'h> Evaluator<'h> {
|
||||
}
|
||||
|
||||
fn subtotal(&mut self, name: &str, args: &[Expr]) -> Ev {
|
||||
if args.len() < 2 {
|
||||
// SUBTOTAL needs function_num and ref1; AGGREGATE needs function_num, options and ref1.
|
||||
let min_args = if name == "AGGREGATE" { 3 } else { 2 };
|
||||
if args.len() < min_args {
|
||||
return err(CellError::Value);
|
||||
}
|
||||
let code = match self.arg_num(args, 0, 0.0) {
|
||||
@@ -1315,7 +1414,8 @@ impl<'h> Evaluator<'h> {
|
||||
// SUBTOTAL documentation); 101-111 also leave out rows hidden by hand.
|
||||
let skip_filtered = name == "SUBTOTAL";
|
||||
let skip_errors = matches!(options, 2 | 3 | 6 | 7);
|
||||
let skip_nested = options <= 3;
|
||||
// SUBTOTAL always leaves out nested SUBTOTAL/AGGREGATE cells (101-111 too); AGGREGATE does so for options 0-3.
|
||||
let skip_nested = name == "SUBTOTAL" || options <= 3;
|
||||
let mut values = Vec::new();
|
||||
let mut k = None;
|
||||
let refs = if name == "AGGREGATE" && func >= 14 { &rest[..1.min(rest.len())] } else { rest };
|
||||
@@ -1406,37 +1506,42 @@ fn same_kind(a: &Value, b: &Value) -> bool {
|
||||
std::mem::discriminant(a) == std::mem::discriminant(b) || (a.is_empty() || b.is_empty())
|
||||
}
|
||||
|
||||
/// Converts an R1C1 reference text to A1 (for INDIRECT(…, FALSE)).
|
||||
pub fn r1c1_to_a1(s: &str, at: CellRef) -> Option<String> {
|
||||
/// Converts an R1C1 reference text to A1 (for INDIRECT(…, FALSE)). `Ok(None)` means the text is
|
||||
/// not R1C1; `Err` means it is R1C1 but points past the sheet.
|
||||
pub fn r1c1_to_a1(s: &str, at: CellRef) -> Result<Option<String>, CellError> {
|
||||
let (sheet, body) = match s.rsplit_once('!') {
|
||||
Some((sh, b)) => (format!("{sh}!"), b),
|
||||
None => (String::new(), s),
|
||||
};
|
||||
let conv = |part: &str| -> Option<String> {
|
||||
let conv = |part: &str| -> Result<Option<String>, CellError> {
|
||||
let u = part.to_ascii_uppercase();
|
||||
let rest = u.strip_prefix('R')?;
|
||||
let (rpart, cpart) = rest.split_once('C')?;
|
||||
let num = |p: &str, base: u32| -> Option<(u32, bool)> {
|
||||
if p.is_empty() {
|
||||
return Some((base, false));
|
||||
}
|
||||
if let Some(inner) = p.strip_prefix('[').and_then(|x| x.strip_suffix(']')) {
|
||||
let d: i64 = inner.parse().ok()?;
|
||||
let v = base as i64 + d;
|
||||
return if v >= 0 { Some((v as u32, false)) } else { None };
|
||||
}
|
||||
let v: u32 = p.parse().ok()?;
|
||||
Some((v.checked_sub(1)?, true))
|
||||
let Some((rpart, cpart)) = u.strip_prefix('R').and_then(|rest| rest.split_once('C')) else { return Ok(None) };
|
||||
// `None`: not R1C1. `Some(None)`: R1C1, but past the sheet.
|
||||
let num = |p: &str, base: u32, max: u32| -> Option<Option<(u32, bool)>> {
|
||||
let (v, absolute) = if p.is_empty() {
|
||||
(Some(i64::from(base)), false)
|
||||
} else if let Some(inner) = p.strip_prefix('[').and_then(|x| x.strip_suffix(']')) {
|
||||
(i64::from(base).checked_add(inner.parse().ok()?), false)
|
||||
} else {
|
||||
(p.parse::<u32>().ok()?.checked_sub(1).map(i64::from), true)
|
||||
};
|
||||
Some(v.filter(|v| (0..i64::from(max)).contains(v)).map(|v| (v as u32, absolute)))
|
||||
};
|
||||
let (r, ra) = num(rpart, at.row)?;
|
||||
let (c, ca) = num(cpart, at.col)?;
|
||||
Some(format!("{}{}{}{}", if ca { "$" } else { "" }, gridcraft_core::col_to_letters(c), if ra { "$" } else { "" }, r + 1))
|
||||
let (Some(r), Some(c)) = (num(rpart, at.row, MAX_ROWS), num(cpart, at.col, MAX_COLS)) else { return Ok(None) };
|
||||
let (Some((r, ra)), Some((c, ca))) = (r, c) else { return Err(CellError::Ref) };
|
||||
Ok(Some(format!("{}{}{}{}", if ca { "$" } else { "" }, gridcraft_core::col_to_letters(c), if ra { "$" } else { "" }, r + 1)))
|
||||
};
|
||||
let out = match body.split_once(':') {
|
||||
Some((a, b)) => format!("{}:{}", conv(a)?, conv(b)?),
|
||||
None => conv(body)?,
|
||||
Some((a, b)) => match (conv(a)?, conv(b)?) {
|
||||
(Some(a), Some(b)) => format!("{a}:{b}"),
|
||||
_ => return Ok(None),
|
||||
},
|
||||
None => match conv(body)? {
|
||||
Some(x) => x,
|
||||
None => return Ok(None),
|
||||
},
|
||||
};
|
||||
Some(format!("{sheet}{out}"))
|
||||
Ok(Some(format!("{sheet}{out}")))
|
||||
}
|
||||
|
||||
/// The aggregates that can fold a reference without building an array.
|
||||
@@ -1492,12 +1597,12 @@ impl Acc {
|
||||
|
||||
// ---------------------------------------------------------------- operators
|
||||
|
||||
fn map_unary(v: &Value, f: &dyn Fn(f64) -> Value, plus: bool) -> Value {
|
||||
fn map_unary(v: &Value, f: &dyn Fn(f64) -> Value, plus: bool, sys: gridcraft_core::DateSystem) -> Value {
|
||||
match v {
|
||||
Value::Array(a) => Value::from(Array { rows: a.rows, cols: a.cols, data: a.data.iter().map(|x| map_unary(x, f, plus)).collect() }),
|
||||
Value::Array(a) => Value::from(Array { rows: a.rows, cols: a.cols, data: a.data.iter().map(|x| map_unary(x, f, plus, sys)).collect() }),
|
||||
Value::Error(e) => Value::Error(*e),
|
||||
Value::Text(_) if plus => v.clone(),
|
||||
other => match other.to_number() {
|
||||
other => match other.to_number_in(sys) {
|
||||
Ok(n) => f(n),
|
||||
Err(e) => Value::Error(e),
|
||||
},
|
||||
@@ -1550,18 +1655,18 @@ fn zip3(c: &Array, t: &Value, f: &Value, g: impl Fn(&Value, &Value, &Value) -> V
|
||||
Value::from(Array { rows, cols, data })
|
||||
}
|
||||
|
||||
pub fn binary_values(op: BinOp, x: &Value, y: &Value) -> Value {
|
||||
zip2(x, y, |a, b| binary_scalar(op, a, b))
|
||||
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))
|
||||
}
|
||||
|
||||
fn binary_scalar(op: BinOp, a: &Value, b: &Value) -> Value {
|
||||
fn binary_scalar(op: BinOp, a: &Value, b: &Value, sys: gridcraft_core::DateSystem) -> Value {
|
||||
if let Value::Error(e) = a {
|
||||
return Value::Error(*e);
|
||||
}
|
||||
if let Value::Error(e) = b {
|
||||
return Value::Error(*e);
|
||||
}
|
||||
let num = |f: fn(f64, f64) -> Value| match (a.to_number(), b.to_number()) {
|
||||
let num = |f: fn(f64, f64) -> Value| match (a.to_number_in(sys), b.to_number_in(sys)) {
|
||||
(Ok(x), Ok(y)) => f(x, y),
|
||||
(Err(e), _) | (_, Err(e)) => Value::Error(e),
|
||||
};
|
||||
|
||||
@@ -898,11 +898,11 @@ impl Calc {
|
||||
// Spill areas of changed anchors also change.
|
||||
for &k in changed {
|
||||
if let Some(r) = wb.sheet(k.0).and_then(|s| s.spill_ranges.get(&k.1)) {
|
||||
seeds.extend(r.iter().take(65536).map(|c| (k.0, c)));
|
||||
seeds.extend(r.iter().map(|c| (k.0, c)));
|
||||
}
|
||||
}
|
||||
for (si, r) in &cleared {
|
||||
seeds.extend(r.iter().take(65536).map(|c| (*si, c)));
|
||||
seeds.extend(r.iter().map(|c| (*si, c)));
|
||||
}
|
||||
let t0 = prof_now();
|
||||
let dirty = self.dirty_closure(wb, &seeds);
|
||||
|
||||
+327
-1
@@ -157,6 +157,21 @@ fn index_with_arrays_of_positions() {
|
||||
assert_eq!(t.num("E7"), 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_area_number_overflow() {
|
||||
let mut t = T::new();
|
||||
t.set("A1", "7");
|
||||
t.set("D1", "9");
|
||||
for (area, expected) in [("1", 7.0), ("2", 9.0)] {
|
||||
t.set("G5", &format!("=INDEX((A1:B2,D1:E2),1,1,{area})"));
|
||||
assert_eq!(t.num("G5"), expected, "area {area}");
|
||||
}
|
||||
for area in ["3", "1E300", "-1", "-1E300"] {
|
||||
t.set("G5", &format!("=INDEX((A1:B2,D1:E2),1,1,{area})"));
|
||||
assert_eq!(t.get("G5"), Value::Error(CellError::Ref), "area {area}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_and_cycles() {
|
||||
let mut t = T::new();
|
||||
@@ -167,6 +182,11 @@ fn errors_and_cycles() {
|
||||
assert!(t.get("B1").is_error());
|
||||
t.set("C1", "=NOSUCHFN(1)");
|
||||
assert_eq!(t.get("C1"), Value::Error(CellError::Name));
|
||||
// `_xludf.` marks a user-defined function, never the built-in of the same name (#726).
|
||||
for f in ["=_xludf.CONCAT(\"a\",\"b\")", "=_xludf.SUM(1,2)", "=_xludf.EOMONTH()"] {
|
||||
t.set("C3", f);
|
||||
assert_eq!(t.get("C3"), Value::Error(CellError::Name), "{f}");
|
||||
}
|
||||
t.set("C2", "=1+");
|
||||
assert_eq!(t.get("C2"), Value::Error(CellError::Name));
|
||||
}
|
||||
@@ -270,6 +290,26 @@ fn circular_references_without_iterative_calculation() {
|
||||
assert_eq!(t.get("A1"), Value::Error(CellError::Circ));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_text_coerces_in_the_workbook_date_system() {
|
||||
for (sys, serial) in [(gridcraft_core::DateSystem::D1900, 1462.0), (gridcraft_core::DateSystem::D1904, 0.0)] {
|
||||
let mut t = T::new();
|
||||
t.wb.date_system = sys;
|
||||
t.set("A1", "'1/1/1904");
|
||||
t.set("B1", "=A1+0");
|
||||
t.set("C1", "=VALUE(A1)");
|
||||
t.set("D1", "=-A1");
|
||||
t.set("E1", "=DATEVALUE(A1)");
|
||||
t.set("F1", "=NUMBERVALUE(A1)");
|
||||
assert_eq!(t.get("A1"), Value::from("1/1/1904"));
|
||||
assert_eq!(t.num("B1"), serial, "{sys:?}");
|
||||
assert_eq!(t.num("C1"), serial, "{sys:?}");
|
||||
assert_eq!(t.num("D1"), -serial, "{sys:?}");
|
||||
assert_eq!(t.num("E1"), serial, "{sys:?}");
|
||||
assert_eq!(t.num("F1"), serial, "{sys:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operators() {
|
||||
let mut t = T::new();
|
||||
@@ -346,6 +386,8 @@ fn arrays_do_not_spill_in_tables() {
|
||||
last_col: false,
|
||||
filter_button: false,
|
||||
filter: vec![],
|
||||
table_type: None,
|
||||
connection_id: None,
|
||||
});
|
||||
t.set("C4", "=SEQUENCE(2)");
|
||||
t.set("E1", "=SEQUENCE(4)");
|
||||
@@ -403,6 +445,22 @@ fn references_functions() {
|
||||
assert_eq!(t.num("B3"), 50.0);
|
||||
t.set("B4", "=INDIRECT(\"A\"&4)");
|
||||
assert_eq!(t.num("B4"), 40.0);
|
||||
// With a1 = FALSE the text must be R1C1: A1-style text is #REF!, not read as A1.
|
||||
t.set("C1", "=INDIRECT(\"R3C1\",FALSE)");
|
||||
assert_eq!(t.num("C1"), 30.0);
|
||||
t.set("C2", "=INDIRECT(\"A3\",FALSE)");
|
||||
assert_eq!(t.get("C2"), Value::Error(CellError::Ref));
|
||||
// Defined names don't depend on the reference style.
|
||||
t.wb.names.push(gridcraft_model::DefinedName {
|
||||
name: "Third".into(),
|
||||
scope: None,
|
||||
formula: "Sheet1!$A$3".into(),
|
||||
comment: String::new(),
|
||||
hidden: false,
|
||||
});
|
||||
t.calc.rebuild(&t.wb);
|
||||
t.set("C3", "=INDIRECT(\"Third\",FALSE)");
|
||||
assert_eq!(t.num("C3"), 30.0);
|
||||
t.set("B5", "=SUM(A1:INDEX(A1:A4,3))");
|
||||
assert_eq!(t.num("B5"), 60.0);
|
||||
t.set("B6", "=SUM(A:A)");
|
||||
@@ -412,6 +470,37 @@ fn references_functions() {
|
||||
assert_eq!(t.num("B6"), 105.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indirect_r1c1_out_of_bounds() {
|
||||
for reference in ["R[9223372036854775807]C", "RC[9223372036854775807]", "R[4294967296]C", "R[4294967291]C", "R[2000000]C", "RC[20000]"] {
|
||||
let mut t = T::new();
|
||||
t.set("D5", &format!("=ROW(INDIRECT(\"{reference}\",FALSE))"));
|
||||
assert_eq!(t.get("D5"), Value::Error(CellError::Ref), "{reference}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indirect_r1c1_past_the_sheet_does_not_fall_back_to_a1() {
|
||||
let mut t = T::new();
|
||||
t.set("RC16385", "78");
|
||||
for reference in ["RC16385", "rc16385", "RC1:RC16385"] {
|
||||
t.set("D5", &format!("=INDIRECT(\"{reference}\",FALSE)"));
|
||||
assert_eq!(t.get("D5"), Value::Error(CellError::Ref), "{reference}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indirect_r1c1_valid_references() {
|
||||
let mut t = T::new();
|
||||
t.set("E6", "11");
|
||||
t.set("A1", "22");
|
||||
t.set("D4", "33");
|
||||
for (reference, expected) in [("R[1]C[1]", 11.0), ("R1C1", 22.0), ("R[-1]C", 33.0)] {
|
||||
t.set("D5", &format!("=INDIRECT(\"{reference}\",FALSE)"));
|
||||
assert_eq!(t.num("D5"), expected, "{reference}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_names() {
|
||||
let mut t = T::new();
|
||||
@@ -501,6 +590,59 @@ fn let_lambda() {
|
||||
assert_eq!(t.num("A4"), 14.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn functions_given_by_name() {
|
||||
let mut t = T::new();
|
||||
for (i, (k, v)) in [("a", 1), ("b", -2), ("a", 3)].iter().enumerate() {
|
||||
t.set(&format!("A{}", i + 1), k);
|
||||
t.set(&format!("B{}", i + 1), &v.to_string());
|
||||
}
|
||||
t.set("D1", "=BYROW(B1:B3,SUM)");
|
||||
assert_eq!(t.num("D2"), -2.0);
|
||||
t.set("E1", "=MAP(B1:B3,ABS)");
|
||||
assert_eq!(t.num("E2"), 2.0);
|
||||
t.set("F1", "=REDUCE(0,B1:B3,SUM)");
|
||||
assert_eq!(t.num("F1"), 2.0);
|
||||
t.set("G1", "=GROUPBY(A1:A3,B1:B3,SUM,0,0)");
|
||||
assert_eq!(t.get("G1"), Value::from("a"));
|
||||
assert_eq!(t.num("H1"), 4.0);
|
||||
// A LET variable of that name is still the variable.
|
||||
t.set("J1", "=LET(ABS,LAMBDA(x,x*10),SUM(MAP(B1:B3,ABS)))");
|
||||
assert_eq!(t.num("J1"), 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lambda_argument_count() {
|
||||
let mut t = T::new();
|
||||
t.wb.names.push(gridcraft_model::DefinedName {
|
||||
name: "AddPair".into(),
|
||||
scope: None,
|
||||
formula: "LAMBDA(a,b,a+b)".into(),
|
||||
comment: String::new(),
|
||||
hidden: false,
|
||||
});
|
||||
t.calc.rebuild(&t.wb);
|
||||
let omit = "LAMBDA(x,y,IF(ISOMITTED(y),\"Missing\",x+y))";
|
||||
t.set("A1", "=LAMBDA(a,b,a+b)(2)");
|
||||
t.set("A2", "=LAMBDA(a,b,a+b)(2,3,4)");
|
||||
t.set("A3", &format!("={omit}(1,)"));
|
||||
t.set("A4", &format!("={omit}(1)"));
|
||||
t.set("A5", &format!("={omit}(1,2)"));
|
||||
t.set("A6", "=LAMBDA(7)()");
|
||||
t.set("A7", "=AddPair(2)");
|
||||
t.set("A8", "=AddPair(2,3)");
|
||||
assert_eq!(t.get("A1"), Value::Error(CellError::Value));
|
||||
assert_eq!(t.get("A2"), Value::Error(CellError::Value));
|
||||
assert_eq!(t.get("A3"), Value::Text("Missing".into()));
|
||||
assert_eq!(t.get("A4"), Value::Error(CellError::Value));
|
||||
assert_eq!(t.num("A5"), 3.0);
|
||||
assert_eq!(t.num("A6"), 7.0);
|
||||
assert_eq!(t.get("A7"), Value::Error(CellError::Value));
|
||||
assert_eq!(t.num("A8"), 5.0);
|
||||
t.set("A9", "=LAMBDA(x,ISOMITTED(x))()");
|
||||
assert_eq!(t.get("A9"), Value::Bool(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_and_sheets() {
|
||||
let mut t = T::new();
|
||||
@@ -549,6 +691,17 @@ fn text_function() {
|
||||
assert_eq!(t.get("A1"), Value::from("1,234.50"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_operator_chains() {
|
||||
let mut t = T::new();
|
||||
t.set("A1", "1");
|
||||
// 1,000 additions: a formula of 3,000 characters, well within Excel's 8,192.
|
||||
t.set("B1", &format!("={}", vec!["A1"; 1000].join("+")));
|
||||
assert_eq!(t.num("B1"), 1000.0);
|
||||
t.set("B2", &format!("=5{}", "+1-1".repeat(600)));
|
||||
assert_eq!(t.num("B2"), 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subtotal_skips_hidden() {
|
||||
let mut t = T::new();
|
||||
@@ -562,6 +715,29 @@ fn subtotal_skips_hidden() {
|
||||
assert_eq!(t.num("B2"), 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subtotal_109_skips_nested_subtotal() {
|
||||
let mut t = T::new();
|
||||
t.set("A1", "2");
|
||||
t.set("A2", "=SUBTOTAL(9,A1)");
|
||||
t.set("C1", "=SUBTOTAL(109,A1:A2)");
|
||||
t.set("C2", "=SUBTOTAL(9,A1:A2)");
|
||||
t.set("C3", "=SUBTOTAL(109,A1)");
|
||||
assert_eq!(t.num("C1"), 2.0);
|
||||
assert_eq!(t.num("C2"), 2.0);
|
||||
assert_eq!(t.num("C3"), 2.0);
|
||||
// Manually hidden rows are still left out by 109 only.
|
||||
let mut t = T::new();
|
||||
t.set("A1", "1");
|
||||
t.set("A2", "2");
|
||||
t.set("A3", "=SUBTOTAL(9,A1:A2)");
|
||||
t.wb.sheet_mut(0).unwrap().rows.insert(1, gridcraft_model::LineInfo { hidden: true, ..Default::default() });
|
||||
t.set("B1", "=SUBTOTAL(109,A1:A3)");
|
||||
t.set("B2", "=SUBTOTAL(9,A1:A3)");
|
||||
assert_eq!(t.num("B1"), 1.0);
|
||||
assert_eq!(t.num("B2"), 3.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).
|
||||
@@ -581,7 +757,14 @@ fn subtotal_skips_filtered_rows() {
|
||||
range: gridcraft_core::RangeRef::parse("A1:A7").unwrap(),
|
||||
header_row: true,
|
||||
totals_row: false,
|
||||
columns: vec![gridcraft_model::TableColumn { name: "Amount".into(), totals: Default::default(), totals_label: None, formula: None }],
|
||||
columns: vec![gridcraft_model::TableColumn {
|
||||
name: "Amount".into(),
|
||||
totals: Default::default(),
|
||||
totals_label: None,
|
||||
formula: None,
|
||||
unique_name: None,
|
||||
xml: None,
|
||||
}],
|
||||
style: String::new(),
|
||||
banded_rows: false,
|
||||
banded_cols: false,
|
||||
@@ -589,6 +772,8 @@ fn subtotal_skips_filtered_rows() {
|
||||
last_col: false,
|
||||
filter_button: true,
|
||||
filter: crit(),
|
||||
table_type: None,
|
||||
connection_id: None,
|
||||
});
|
||||
sh.autofilter = Some(AutoFilter { range: gridcraft_core::RangeRef::parse("C1:C7").unwrap(), criteria: crit() });
|
||||
// Rows 3 and 5 are filtered out; row 9 (outside both filters) is hidden by hand.
|
||||
@@ -608,6 +793,23 @@ fn subtotal_skips_filtered_rows() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregate_without_data_argument_is_an_error() {
|
||||
let mut t = T::new();
|
||||
t.set("A1", "1");
|
||||
t.set("A2", "2");
|
||||
t.set("D1", "=AGGREGATE(9,0)");
|
||||
t.set("D2", "=AGGREGATE(4,0)");
|
||||
t.set("D3", "=AGGREGATE(9,0,A1:A2)");
|
||||
t.set("D4", "=AGGREGATE(9,0,A3:A4)");
|
||||
t.set("D5", "=AGGREGATE(9,0,A1:A2,A1:A2)");
|
||||
assert_eq!(t.get("D1"), Value::Error(CellError::Value));
|
||||
assert_eq!(t.get("D2"), Value::Error(CellError::Value));
|
||||
assert_eq!(t.num("D3"), 3.0);
|
||||
assert_eq!(t.num("D4"), 0.0);
|
||||
assert_eq!(t.num("D5"), 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recalc_all_after_load() {
|
||||
let mut t = T::new();
|
||||
@@ -968,3 +1170,127 @@ fn trim_references() {
|
||||
t.set("K20", "=SUM(Q1:.Q9)");
|
||||
assert_eq!(t.get("K20"), Value::Error(CellError::Calc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_width_is_the_column_width_dialog_value_rounded() {
|
||||
// #735: Excel 365 values. Widths in px as `home.columnWidth {"chars"}` sets them
|
||||
// (chars * 7 + 5): 10.71 -> 80, 12 -> 89, 10.57 -> 79, 2.86 -> 25; B keeps the default 8.43 (64).
|
||||
let mut t = T::new();
|
||||
let sh = t.wb.sheet_mut(0).unwrap();
|
||||
for (col, px) in [(2, 80.0), (3, 89.0), (4, 79.0), (5, 25.0)] {
|
||||
sh.cols.insert(col, gridcraft_model::LineInfo { size: Some(px), ..Default::default() });
|
||||
}
|
||||
for (at, col) in [("H1", "B"), ("H2", "C"), ("H3", "D"), ("H4", "E"), ("H5", "F")] {
|
||||
t.set(at, &format!("=CELL(\"width\",{col}1)"));
|
||||
}
|
||||
assert_eq!([t.num("H1"), t.num("H2"), t.num("H3"), t.num("H4"), t.num("H5")], [8.0, 11.0, 12.0, 11.0, 3.0]);
|
||||
}
|
||||
|
||||
/// Expected results are Excel's, from Apache POI's `DGet.xls` (rows 46-49): a criteria cell holding
|
||||
/// a formula under a label that names no field is evaluated for every record.
|
||||
#[test]
|
||||
fn database_functions_with_computed_criteria() {
|
||||
let mut t = T::new();
|
||||
let db = [
|
||||
["name", "age", "size", "headgear", "glasses"],
|
||||
["Huey", "6", "45", "cap~", "FALSE"],
|
||||
["Dewey", "7", "fourty-six", "cap?", "FALSE"],
|
||||
["Louie", "8", "47", "cap*", "FALSE"],
|
||||
["Donald", "32", "70", "sailor's hat", "FALSE"],
|
||||
["Scrooge", "62", "65", "top hat", "TRUE"],
|
||||
];
|
||||
for (r, row) in db.iter().enumerate() {
|
||||
for (c, v) in row.iter().enumerate() {
|
||||
t.set(&format!("{}{}", ["F", "G", "H", "I", "J"][c], r + 2), v);
|
||||
}
|
||||
}
|
||||
t.set("G45", "7");
|
||||
let cases = [
|
||||
// Relative references are to the first record's row and move to each record's row;
|
||||
// `$G$45` stays put.
|
||||
("G", "=G2<$G$45", "Dewey"),
|
||||
// Column labels stand for the record's fields.
|
||||
("H", "=glasses=TRUE", "Scrooge"),
|
||||
("I", "=age<7", "Huey"),
|
||||
("J", "=size/7=10", "Donald"),
|
||||
// An absolute reference to a cell that is not the first record's.
|
||||
("K", "=H3=$L$1", "Donald"),
|
||||
];
|
||||
t.set("L1", "70");
|
||||
for (i, (col, formula, _)) in cases.iter().enumerate() {
|
||||
t.set(&format!("{col}46"), "Formula");
|
||||
t.set(&format!("{col}47"), formula);
|
||||
t.set(&format!("B{}", 46 + i), &format!("=DGET($F$2:$J$7,\"name\",{col}46:{col}47)"));
|
||||
}
|
||||
for (i, (_, formula, want)) in cases.iter().enumerate() {
|
||||
assert_eq!(t.get(&format!("B{}", 46 + i)), Value::text(*want), "{formula}");
|
||||
}
|
||||
// Every database function takes them, ANDed with the other criteria of their row.
|
||||
// Text sorts after numbers, so Dewey's "fourty-six" is > 40.
|
||||
t.set("L47", "=size>40");
|
||||
t.set("M46", "age");
|
||||
t.set("M47", ">6");
|
||||
t.set("B51", "=DCOUNT($F$2:$J$7,\"age\",I46:I47)");
|
||||
t.set("B52", "=DSUM($F$2:$J$7,\"age\",G46:G47)");
|
||||
t.set("B53", "=DCOUNTA($F$2:$J$7,\"name\",L46:M47)");
|
||||
assert_eq!(t.num("B51"), 1.0);
|
||||
assert_eq!(t.num("B52"), 7.0);
|
||||
assert_eq!(t.num("B53"), 4.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_extreme_arguments_return_ref() {
|
||||
let wb = Workbook::new();
|
||||
for formula in [
|
||||
"=OFFSET(B2,1E300,0)",
|
||||
"=OFFSET(B2,0,1E300)",
|
||||
"=OFFSET(B2,0,0,1E300,1)",
|
||||
"=OFFSET(B2,0,0,1,1E300)",
|
||||
"=OFFSET(B2,-1E300,0)",
|
||||
"=OFFSET(B2,0,-1E300)",
|
||||
"=OFFSET(B2,0,0,-1E300,1)",
|
||||
"=OFFSET(B2,0,0,1,-1E300)",
|
||||
"=OFFSET(B2,1E300,0,-1E300,1)",
|
||||
"=OFFSET(B2,0,1E300,1,-1E300)",
|
||||
"=OFFSET(B2,-1E300,0,-1E300,1)",
|
||||
] {
|
||||
assert_eq!(crate::evaluate(&wb, 0, c("D1"), formula), Value::Error(CellError::Ref), "{formula}");
|
||||
}
|
||||
for (formula, expected) in [
|
||||
("=ROW(OFFSET(B2,1.9,0))", 3.0),
|
||||
("=COLUMN(OFFSET(B2,0,-1.9))", 1.0),
|
||||
("=ROWS(OFFSET(B2,0,0,-2,1))", 2.0),
|
||||
("=COLUMNS(OFFSET(B2,0,0,1,-2))", 2.0),
|
||||
("=ROWS(OFFSET(A1,0,0,1048576,1))", 1048576.0),
|
||||
] {
|
||||
assert_eq!(crate::evaluate(&wb, 0, c("D1"), formula), Value::Number(expected), "{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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ impl Fmt {
|
||||
pub fn new(code: &str) -> Fmt {
|
||||
let t = code.trim();
|
||||
let general = t.is_empty() || t.eq_ignore_ascii_case("general");
|
||||
Fmt { general, fmt: NumberFormat::parse(if general { "General" } else { t }) }
|
||||
Fmt { general, fmt: NumberFormat::parse_cached(if general { "General" } else { t }) }
|
||||
}
|
||||
/// Formats `v`; `step` (axis labels) fixes the decimals for General.
|
||||
pub fn format(&self, v: f64, step: Option<f64>) -> String {
|
||||
|
||||
@@ -124,6 +124,9 @@ impl Measure for ApproxMeasure {
|
||||
pub struct ChartData {
|
||||
pub categories: 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)]
|
||||
pub palette: Vec<Rgba>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -138,7 +138,8 @@ fn circle(cx: f32, cy: f32, r: f32) -> Vec<[f32; 2]> {
|
||||
}
|
||||
|
||||
fn rounded_rect(x: f32, y: f32, w: f32, h: f32, r: f32) -> Vec<[f32; 2]> {
|
||||
let r = r.clamp(0.0, w.min(h) / 2.0);
|
||||
// A negative or NaN extent gives an upper bound of 0 (clamp panics if max < min or NaN).
|
||||
let r = r.clamp(0.0, (w.min(h) / 2.0).max(0.0));
|
||||
if r < 0.5 {
|
||||
return vec![[x, y], [x + w, y], [x + w, y + h], [x, y + h]];
|
||||
}
|
||||
|
||||
+32
-15
@@ -129,7 +129,7 @@ fn plot_kind(chart: &Chart, s: &SeriesData) -> ChartKind {
|
||||
fn point_color(data: &ChartData, j: usize) -> Rgba {
|
||||
match (j, data.series.first()) {
|
||||
(0, Some(s)) => s.color,
|
||||
_ => series_color(&Theme::default(), j),
|
||||
_ => j.checked_rem(data.palette.len()).and_then(|k| data.palette.get(k)).copied().unwrap_or_else(|| series_color(&Theme::default(), j)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +190,7 @@ fn sanitize_vals(v: &[Option<f64>]) -> Vec<Option<f64>> {
|
||||
|
||||
fn sanitize(d: &ChartData) -> ChartData {
|
||||
ChartData {
|
||||
palette: d.palette.iter().take(MAX_POINTS).copied().collect(),
|
||||
categories: d.categories.iter().take(MAX_POINTS).cloned().collect(),
|
||||
series: d
|
||||
.series
|
||||
@@ -254,7 +255,7 @@ fn draw_kind(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
Doughnut => pie(ctx, chart, data, area, 0.5, false),
|
||||
Sunburst => pie(ctx, chart, data, area, 0.2, true),
|
||||
Treemap => treemap(ctx, chart, data, area),
|
||||
Funnel => funnel(ctx, data, area),
|
||||
Funnel => funnel(ctx, chart, data, area),
|
||||
Radar => radar(ctx, chart, data, area),
|
||||
_ => {
|
||||
let area = axis_titles(ctx, chart, area);
|
||||
@@ -266,8 +267,8 @@ fn draw_kind(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
Scatter | ScatterLines | Bubble => scatter(ctx, chart, data, area),
|
||||
Histogram => histogram(ctx, chart, data, area),
|
||||
Waterfall => waterfall(ctx, chart, data, area),
|
||||
BoxWhisker => box_whisker(ctx, data, area),
|
||||
Stock => stock(ctx, data, area),
|
||||
BoxWhisker => box_whisker(ctx, chart, data, area),
|
||||
Stock => stock(ctx, chart, data, area),
|
||||
_ => cartesian(ctx, chart, data, area, false),
|
||||
}
|
||||
}
|
||||
@@ -970,6 +971,8 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
|
||||
let nslots = slot_keys.len().max(1) as f32;
|
||||
let bw = cw / (nslots + 1.5);
|
||||
let labels_on = chart.data_labels && ncat <= MAX_LABELS;
|
||||
// Area labels wait until every area is filled, so a later (stacked) area can't paint over them.
|
||||
let mut area_labels = Vec::new();
|
||||
|
||||
// Areas first, then columns, then lines on top.
|
||||
for pass in 0..3 {
|
||||
@@ -998,6 +1001,13 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
|
||||
let c = center(j);
|
||||
top.push([c, vpos(t, &sc)]);
|
||||
base.push([c, vpos(b, &sc)]);
|
||||
// Source values (not stack totals), just above the top edge like the line labels.
|
||||
if labels_on
|
||||
&& sp.is_some()
|
||||
&& let Some(v) = s.values.get(j).copied().flatten()
|
||||
{
|
||||
area_labels.push((c, vpos(t, &sc) - 5.0, fmt.format(v, None)));
|
||||
}
|
||||
}
|
||||
if top.len() == 1 {
|
||||
// A single point: give it some width.
|
||||
@@ -1072,6 +1082,9 @@ fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx, horizonta
|
||||
}
|
||||
}
|
||||
}
|
||||
for (x, y, t) in area_labels.drain(..) {
|
||||
ctx.text(x, y, &t, LABEL_SIZE, TEXT, HAlign::Center, VAlign::Bottom);
|
||||
}
|
||||
}
|
||||
// Trendlines on top: x is the category number 1..n.
|
||||
let mut trend_labels = Vec::new();
|
||||
@@ -1201,10 +1214,12 @@ fn histogram(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
bins = 1;
|
||||
}
|
||||
let width = if bins == 1 && span.partial_cmp(&0.0) != Some(std::cmp::Ordering::Greater) { 1.0 } else { span / bins as f64 };
|
||||
// Upper edge of each bin, exactly as shown in the labels below. Bins are right-inclusive (the first is closed on both sides), so a value
|
||||
// equal to an edge belongs to the bin that ends there.
|
||||
let edges: Vec<f64> = (0..bins).map(|b| if b + 1 == bins { hi.max(lo + b as f64 * width) } else { lo + (b + 1) as f64 * width }).collect();
|
||||
let mut counts = vec![0usize; bins];
|
||||
for v in &vals {
|
||||
let b = if width > 0.0 { ((v - lo) / width).floor() } else { 0.0 };
|
||||
let b = if b.is_finite() && b > 0.0 { (b as usize).min(bins - 1) } else { 0 };
|
||||
let b = edges.partition_point(|e| e < v).min(bins - 1);
|
||||
if let Some(c) = counts.get_mut(b) {
|
||||
*c += 1;
|
||||
}
|
||||
@@ -1213,7 +1228,7 @@ fn histogram(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
let labels: Vec<String> = (0..bins)
|
||||
.map(|b| {
|
||||
let a = lo + b as f64 * width;
|
||||
let e = if b + 1 == bins { hi.max(a) } else { a + width };
|
||||
let e = edges.get(b).copied().unwrap_or(a);
|
||||
let open = if b == 0 { '[' } else { '(' };
|
||||
format!("{open}{}, {}]", f.format(a, Some(width / 10.0)), f.format(e, Some(width / 10.0)))
|
||||
})
|
||||
@@ -1301,7 +1316,7 @@ fn waterfall(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
|
||||
// ---------------------------------------------------------------- funnel
|
||||
|
||||
fn funnel(ctx: &mut Ctx, data: &ChartData, area: Bx) {
|
||||
fn funnel(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
let Some(s) = data.series.first() else { return };
|
||||
let n = data.categories.len().max(s.values.len()).min(MAX_CATS);
|
||||
if n == 0 {
|
||||
@@ -1331,9 +1346,11 @@ fn funnel(ctx: &mut Ctx, data: &ChartData, area: Bx) {
|
||||
}
|
||||
if show {
|
||||
let yc = y + bh / 2.0;
|
||||
let t = f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None);
|
||||
let color = if ctx.tw(&t, LABEL_SIZE) + 4.0 <= bw { on(s.color) } else { TEXT };
|
||||
ctx.text(plot.cx(), yc, &t, LABEL_SIZE, color, HAlign::Center, VAlign::Middle);
|
||||
if chart.data_labels {
|
||||
let t = f.format(s.values.get(j).copied().flatten().unwrap_or(0.0), None);
|
||||
let color = if ctx.tw(&t, LABEL_SIZE) + 4.0 <= bw { on(s.color) } else { TEXT };
|
||||
ctx.text(plot.cx(), yc, &t, LABEL_SIZE, color, HAlign::Center, VAlign::Middle);
|
||||
}
|
||||
let l = ctx.fit(labels.get(j).map(String::as_str).unwrap_or(""), LABEL_SIZE, lw - 8.0);
|
||||
ctx.text(plot.x - 6.0, yc, &l, LABEL_SIZE, TEXT, HAlign::Right, VAlign::Middle);
|
||||
}
|
||||
@@ -1557,12 +1574,12 @@ fn quantile(sorted: &[f64], q: f64) -> f64 {
|
||||
a + (b - a) * frac
|
||||
}
|
||||
|
||||
fn box_whisker(ctx: &mut Ctx, data: &ChartData, area: Bx) {
|
||||
fn box_whisker(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
let labels: Vec<String> = data.series.iter().map(|s| s.name.clone()).collect();
|
||||
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, true);
|
||||
let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
|
||||
if !plot.ok() {
|
||||
return;
|
||||
}
|
||||
@@ -1601,7 +1618,7 @@ fn box_whisker(ctx: &mut Ctx, data: &ChartData, area: Bx) {
|
||||
|
||||
// ---------------------------------------------------------------- stock
|
||||
|
||||
fn stock(ctx: &mut Ctx, data: &ChartData, area: Bx) {
|
||||
fn stock(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx) {
|
||||
let ncat = data.categories.len().max(data.series.iter().map(|s| s.values.len()).max().unwrap_or(0)).min(MAX_CATS);
|
||||
if ncat == 0 {
|
||||
return;
|
||||
@@ -1610,7 +1627,7 @@ fn stock(ctx: &mut Ctx, 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, true);
|
||||
let plot = frame(ctx, area, CatAxis::Labels(&labels), &ValAxis { scale: ys, fmt: &f }, None, false, chart.gridlines);
|
||||
if !plot.ok() {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ pub(crate) const MAX_TRENDLINES: usize = 16;
|
||||
|
||||
/// Tints used for series beyond the six theme accents: lighter, darker, lighter still…
|
||||
const CYCLE_TINTS: [f64; 6] = [0.0, 0.4, -0.25, 0.6, -0.5, 0.2];
|
||||
/// `series_color` repeats after this many colours (six accents × the tints).
|
||||
pub(crate) const PALETTE_CYCLE: usize = 6 * CYCLE_TINTS.len();
|
||||
|
||||
/// Colour of the `i`-th series: theme accents 1–6, then tinted variations of them.
|
||||
pub fn series_color(theme: &Theme, i: usize) -> Rgba {
|
||||
@@ -145,7 +147,7 @@ fn category_texts(wb: &Workbook, sheet: usize, formula: &str) -> Vec<String> {
|
||||
if let Some(c) = at {
|
||||
let code = &wb.styles.get(s.style_id(c)).num_fmt.0;
|
||||
if !code.eq_ignore_ascii_case("general") {
|
||||
return format_value(v, &NumberFormat::parse(code), wb.date_system).text;
|
||||
return format_value(v, &NumberFormat::parse_cached(code), wb.date_system).text;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -239,5 +241,6 @@ 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());
|
||||
ChartData { categories, series }
|
||||
let palette = default_palette(&wb.theme, n.min(PALETTE_CYCLE));
|
||||
ChartData { categories, series, palette }
|
||||
}
|
||||
|
||||
+167
-12
@@ -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 {
|
||||
palette: vec![],
|
||||
categories: cats.iter().map(|s| s.to_string()).collect(),
|
||||
series: vec![
|
||||
series("North", &[12000.0, -4000.0, 23000.0, 31000.0, 18000.0, 27000.0], [20, 96, 130, 255], k1),
|
||||
@@ -362,7 +363,7 @@ 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 { categories: vec![], series: vec![] }, 400.0, 300.0, &ApproxMeasure);
|
||||
let empty = render(&c, &ChartData { 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());
|
||||
@@ -420,6 +421,16 @@ fn rasterize_bar_center_has_bar_color() {
|
||||
assert!(rasterize(&[], 100_000, 100_000, [0; 4]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rasterize_does_not_panic_on_negative_rect_extents() {
|
||||
let red = [255, 0, 0, 255];
|
||||
for (w, h) in [(-10.0, 10.0), (10.0, -10.0), (-10.0, -10.0), (f32::NAN, f32::NAN)] {
|
||||
let rect = Prim::Rect { x: 5.0, y: 5.0, w, h, fill: Some(red), stroke: Some((red, 2.0)), radius: 0.0 };
|
||||
let img = rasterize(&[rect], 20, 20, [0, 0, 0, 0]);
|
||||
assert_eq!(img.len(), 20 * 20 * 4);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pie_starts_at_twelve_clockwise() {
|
||||
let c = chart(ChartKind::Pie);
|
||||
@@ -445,7 +456,8 @@ fn pie_starts_at_twelve_clockwise() {
|
||||
#[test]
|
||||
fn negative_values_put_axis_below_zero() {
|
||||
let c = chart(ChartKind::ColumnClustered);
|
||||
let d = ChartData { categories: vec![], series: vec![series("N", &[-5.0, 3.0, -2.0], [1, 2, 3, 255], 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 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:?}");
|
||||
@@ -456,8 +468,13 @@ fn negative_values_put_axis_below_zero() {
|
||||
fn edge_cases_never_panic() {
|
||||
let m = ApproxMeasure;
|
||||
let empty = ChartData::default();
|
||||
let zeros = ChartData { categories: vec!["a".into(), "b".into()], series: vec![series("z", &[0.0, 0.0], [0, 0, 0, 255], ChartKind::Line)] };
|
||||
let zeros = ChartData {
|
||||
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 {
|
||||
palette: vec![],
|
||||
categories: vec![String::new(); 3],
|
||||
series: vec![SeriesData {
|
||||
name: "w".repeat(500),
|
||||
@@ -488,7 +505,7 @@ 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 { categories: vec![], series: vec![series("big", &vals, [9, 9, 9, 255], ChartKind::Line)] };
|
||||
let d = ChartData { 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()
|
||||
@@ -513,7 +530,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 { categories: cats.clone(), series: vec![s] };
|
||||
let d = ChartData { palette: vec![], categories: cats.clone(), series: vec![s] };
|
||||
render(&chart(ChartKind::Line), &d, 600.0, 400.0, m)
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
@@ -530,7 +547,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 { categories: cats, series: vec![s] };
|
||||
let d = ChartData { palette: vec![], categories: cats, series: vec![s] };
|
||||
assert_sane(&render(&chart(ChartKind::Line), &d, 600.0, 400.0, &m), 600.0, 400.0);
|
||||
}
|
||||
|
||||
@@ -540,7 +557,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 { categories: vec!["a".into(), "b".into(), "c".into(), "d".into()], series: vec![s] };
|
||||
let d = ChartData { 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();
|
||||
@@ -617,7 +634,7 @@ fn fuzz_random_data_and_sizes() {
|
||||
})
|
||||
.collect();
|
||||
let ncat = (r.next() % 50) as usize;
|
||||
let d = ChartData { categories: (0..ncat).map(|i| "c".repeat(i % 7) + &i.to_string()).collect(), series };
|
||||
let d = ChartData { 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);
|
||||
@@ -792,7 +809,7 @@ 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 { categories: ["a", "b", "c", "d"].map(String::from).to_vec(), series: vec![s.clone()] };
|
||||
let d = ChartData { 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);
|
||||
@@ -816,7 +833,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 { categories: vec!["a".into()], series: vec![s] };
|
||||
let one = ChartData { palette: vec![], categories: vec!["a".into()], series: vec![s] };
|
||||
assert!(lines_of(&render(&c, &one, 600.0, 400.0, &m), tc).is_empty());
|
||||
}
|
||||
|
||||
@@ -836,7 +853,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 { categories: vec![], series: vec![s] };
|
||||
let d = ChartData { 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);
|
||||
@@ -851,6 +868,7 @@ fn scatter_trendlines_use_x_values_and_stay_in_the_plot() {
|
||||
#[test]
|
||||
fn treemap_and_histogram_shapes() {
|
||||
let d = ChartData {
|
||||
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)],
|
||||
};
|
||||
@@ -871,12 +889,34 @@ 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 { categories: vec![], series: vec![series("h", &vals, [10, 20, 30, 255], ChartKind::Histogram)] };
|
||||
let d = ChartData { 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);
|
||||
}
|
||||
|
||||
#[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 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()
|
||||
};
|
||||
// Labels are "[0, 7.5]" and "(7.5, 15.0]": 7.5 belongs to the first bar, so the counts are 2 and 1.
|
||||
let h = heights(&[0.0, 7.5, 15.0]);
|
||||
assert_eq!(h.len(), 2);
|
||||
assert!((h[0] / h[1] - 2.0).abs() < 0.01, "{h:?}");
|
||||
let h = heights(&[-15.0, -7.5, 0.0]);
|
||||
assert!((h[0] / h[1] - 2.0).abs() < 0.01, "{h:?}");
|
||||
// A value off the boundary still goes right: [0, 7.5] holds 1, (7.5, 15.0] holds 2.
|
||||
let h = heights(&[0.0, 7.75, 15.0]);
|
||||
assert!((h[1] / h[0] - 2.0).abs() < 0.01, "{h:?}");
|
||||
// Five values, three bins [0,5] (5,10] (10,15]: counts 3, 1, 1.
|
||||
let h = heights(&[0.0, 5.0, 5.0, 10.0, 15.0]);
|
||||
assert_eq!(h.len(), 3);
|
||||
assert!((h[0] / h[1] - 3.0).abs() < 0.01 && (h[1] - h[2]).abs() < 0.01, "{h:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn union_series_references() {
|
||||
let mut wb = Workbook::new();
|
||||
@@ -902,3 +942,118 @@ fn union_series_references() {
|
||||
c.series[0].values = format!("{}Sheet1!$B$1,Sheet1!$B$2{}", "(".repeat(5000), ")".repeat(5000));
|
||||
let _ = resolve(&wb, 0, &c);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_and_stock_charts_honour_the_gridlines_flag() {
|
||||
// Horizontal full-width lines in the gridline colour are the value-axis gridlines.
|
||||
let grid: Rgba = [217, 217, 217, 255];
|
||||
let is_grid = |p: &Prim| match p {
|
||||
Prim::Line { color, pts, .. } => *color == grid && pts.len() == 2 && pts[0][1] == pts[1][1] && pts[1][0] - pts[0][0] > 200.0,
|
||||
_ => false,
|
||||
};
|
||||
let grids = |prims: &[Prim]| prims.iter().filter(|p| is_grid(p)).count();
|
||||
for kind in [ChartKind::BoxWhisker, ChartKind::Stock, ChartKind::ColumnClustered] {
|
||||
let mut d = sample(kind);
|
||||
d.series.push(series("Close", &[10000.0, 12000.0, 9000.0, 14000.0, 20000.0, 16000.0], [90, 90, 90, 255], kind));
|
||||
let mut c = chart(kind);
|
||||
let on = render(&c, &d, 500.0, 350.0, &ApproxMeasure);
|
||||
c.gridlines = false;
|
||||
let off = render(&c, &d, 500.0, 350.0, &ApproxMeasure);
|
||||
assert!(grids(&on) > 0, "{kind:?}: gridlines on must draw lines");
|
||||
assert_eq!(grids(&off), 0, "{kind:?}: gridlines off must hide them");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn funnel_value_labels_follow_the_data_labels_flag() {
|
||||
let data = ChartData {
|
||||
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)],
|
||||
};
|
||||
let mut c = chart(ChartKind::Funnel);
|
||||
c.data_labels = false;
|
||||
let off = render(&c, &data, 500.0, 350.0, &ApproxMeasure);
|
||||
c.data_labels = true;
|
||||
let on = render(&c, &data, 500.0, 350.0, &ApproxMeasure);
|
||||
let values = ["$11,111", "$22,222", "$33,333"];
|
||||
for v in values {
|
||||
assert!(texts(&on).contains(&v), "{v} missing with labels on");
|
||||
assert!(!texts(&off).contains(&v), "{v} drawn with labels off");
|
||||
}
|
||||
for cat in ["a", "b", "c"] {
|
||||
assert!(texts(&off).contains(&cat));
|
||||
assert!(texts(&on).contains(&cat));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pie_points_follow_workbook_theme() {
|
||||
let mut wb = Workbook::new();
|
||||
wb.theme.colors[4] = 0x4F6D8A;
|
||||
wb.theme.colors[5] = 0x8AA1B8;
|
||||
let mut c = chart(ChartKind::Pie);
|
||||
c.series = vec![Series {
|
||||
name: None,
|
||||
categories: None,
|
||||
values: "{5,3}".into(),
|
||||
bubble_sizes: None,
|
||||
color: None,
|
||||
secondary: false,
|
||||
kind: None,
|
||||
smooth: false,
|
||||
trendlines: Vec::new(),
|
||||
}];
|
||||
let d = resolve(&wb, 0, &c);
|
||||
let expected = vec![series_color(&wb.theme, 0), series_color(&wb.theme, 1)];
|
||||
assert_eq!(expected, vec![[0x4F, 0x6D, 0x8A, 0xFF], [0x8A, 0xA1, 0xB8, 0xFF]]);
|
||||
let legend: Vec<Rgba> = legend_entries(&c, &d).iter().map(|e| e.1).collect();
|
||||
assert_eq!(legend, expected);
|
||||
let fills: Vec<Rgba> = render(&c, &d, 480.0, 288.0, &ApproxMeasure)
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
Prim::Wedge { fill, .. } => Some(*fill),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(fills, expected);
|
||||
for kind in [ChartKind::Doughnut, ChartKind::Treemap, ChartKind::Sunburst] {
|
||||
c.kind = kind;
|
||||
let legend: Vec<Rgba> = legend_entries(&c, &d).iter().map(|e| e.1).collect();
|
||||
assert_eq!(legend, expected, "{kind:?}");
|
||||
}
|
||||
// Past the 36-colour cycle the points keep following the workbook theme.
|
||||
c.kind = ChartKind::Pie;
|
||||
c.series[0].values = format!("{{{}}}", ["1"; 40].join(","));
|
||||
let d = resolve(&wb, 0, &c);
|
||||
let legend: Vec<Rgba> = legend_entries(&c, &d).iter().map(|e| e.1).collect();
|
||||
assert_eq!(legend.len(), 40);
|
||||
assert_eq!(legend.get(37), Some(&series_color(&wb.theme, 37)));
|
||||
assert_ne!(series_color(&wb.theme, 37), series_color(&gridcraft_model::Theme::default(), 37));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_charts_draw_source_value_labels_only_when_enabled() {
|
||||
for kind in [ChartKind::Area, ChartKind::AreaStacked] {
|
||||
let d = sample(kind);
|
||||
let mut c = chart(kind);
|
||||
c.data_labels = false;
|
||||
let off = render(&c, &d, 640.0, 400.0, &ApproxMeasure);
|
||||
c.data_labels = true;
|
||||
let on = render(&c, &d, 640.0, 400.0, &ApproxMeasure);
|
||||
let count = |p: &[Prim], v: &str| texts(p).iter().filter(|t| **t == v).count();
|
||||
// One label per point: 3 series x 6 categories.
|
||||
assert_eq!(texts(&on).len(), texts(&off).len() + 18, "{kind:?}");
|
||||
for v in ["$12,000", "$38,000", "$9,000", "$27,000"] {
|
||||
assert_eq!(count(&on, v), count(&off, v) + 1, "{kind:?}: {v}");
|
||||
}
|
||||
// Stacked areas label the source values, not cumulative totals (12,000 + 38,000).
|
||||
assert_eq!(count(&on, "$50,000"), count(&off, "$50,000"), "{kind:?}: cumulative total shown");
|
||||
let polys = |p: &[Prim]| p.iter().filter(|p| matches!(p, Prim::Polygon { .. })).cloned().collect::<Vec<_>>();
|
||||
assert_eq!(polys(&on), polys(&off));
|
||||
// The first series' labels are drawn after every area, so the next area can't cover them.
|
||||
let last_poly = on.iter().rposition(|p| matches!(p, Prim::Polygon { .. }));
|
||||
let first_label = on.iter().position(|p| matches!(p, Prim::Text { text, .. } if text == "$12,000"));
|
||||
assert!(first_label > last_poly, "{kind:?}: label drawn under a later area");
|
||||
}
|
||||
}
|
||||
|
||||
+21
-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;
|
||||
}
|
||||
@@ -234,6 +241,18 @@ impl fmt::Display for RangeRef {
|
||||
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 letters_roundtrip() {
|
||||
for (c, s) in [(0, "A"), (25, "Z"), (26, "AA"), (51, "AZ"), (52, "BA"), (701, "ZZ"), (702, "AAA"), (16383, "XFD")] {
|
||||
|
||||
+22
-2
@@ -59,6 +59,14 @@ pub fn serial_from_unix_ms(ms: f64) -> f64 {
|
||||
25569.0 + ms / 86_400_000.0
|
||||
}
|
||||
|
||||
/// Converts a 1900-system serial (as the clock supplies it) to the serial of the same instant in `sys`.
|
||||
pub fn serial_in_system(sys: DateSystem, serial_1900: f64) -> f64 {
|
||||
match sys {
|
||||
DateSystem::D1900 => serial_1900,
|
||||
DateSystem::D1904 => serial_1900 - 1462.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn civil_from_days(z: i64) -> (i32, u32, u32) {
|
||||
let z = z + 719468;
|
||||
let era = if z >= 0 { z } else { z - 146096 } / 146097;
|
||||
@@ -95,6 +103,14 @@ pub fn days_in_month_in(sys: DateSystem, y: i32, m: u32) -> u32 {
|
||||
if sys == DateSystem::D1900 && y == 1900 && m == 2 { 29 } else { days_in_month(y, m) }
|
||||
}
|
||||
|
||||
/// Last serial that is a valid date in a date system: 9999-12-31 (2958465 in the 1900 system, 2957003 in the 1904 system).
|
||||
pub fn max_serial(sys: DateSystem) -> i64 {
|
||||
match sys {
|
||||
DateSystem::D1900 => 2_958_465,
|
||||
DateSystem::D1904 => 2_958_465 - 1462,
|
||||
}
|
||||
}
|
||||
|
||||
/// Serial for a date. Month and day overflow roll over like `DATE()` (month 13 = January next
|
||||
/// year, day 0 = last day of the previous month), in the date system's own calendar: in the 1900
|
||||
/// system February 1900 has 29 days, so `(1900, 2, 29)` and `(1900, 3, 0)` are serial 60.
|
||||
@@ -117,7 +133,7 @@ pub fn serial_from_ymd(sys: DateSystem, year: i64, month: i64, day: i64) -> Opti
|
||||
DateSystem::D1904 => first - UNIX_1904_01_01,
|
||||
};
|
||||
let serial = first.checked_add(day)?.checked_sub(1)?;
|
||||
if !(0..=2_958_465).contains(&serial) {
|
||||
if !(0..=max_serial(sys)).contains(&serial) {
|
||||
return None;
|
||||
}
|
||||
Some(serial as f64)
|
||||
@@ -125,7 +141,7 @@ pub fn serial_from_ymd(sys: DateSystem, year: i64, month: i64, day: i64) -> Opti
|
||||
|
||||
/// Date/time from a serial. `None` for negative or too-large serials.
|
||||
pub fn datetime_from_serial(sys: DateSystem, serial: f64) -> Option<DateTime> {
|
||||
if !serial.is_finite() || !(0.0..2_958_466.0).contains(&serial) {
|
||||
if !serial.is_finite() || !(0.0..max_serial(sys) as f64 + 1.0).contains(&serial) {
|
||||
return None;
|
||||
}
|
||||
let mut day = serial.floor() as i64;
|
||||
@@ -197,6 +213,10 @@ mod tests {
|
||||
assert_eq!(serial_from_ymd(s, 2000, 1, 1), Some(36526.0));
|
||||
assert_eq!(serial_from_ymd(s, 2026, 10, 7), Some(46302.0));
|
||||
assert_eq!(serial_from_ymd(s, 9999, 12, 31), Some(2958465.0));
|
||||
assert_eq!(serial_from_ymd(DateSystem::D1904, 9999, 12, 31), Some(2957003.0));
|
||||
assert_eq!(serial_from_ymd(DateSystem::D1904, 9999, 12, 32), None);
|
||||
assert!(datetime_from_serial(DateSystem::D1904, 2957003.5).is_some());
|
||||
assert!(datetime_from_serial(DateSystem::D1904, 2957004.0).is_none());
|
||||
assert_eq!(serial_from_ymd(s, 2020, 13, 1), serial_from_ymd(s, 2021, 1, 1));
|
||||
assert_eq!(serial_from_ymd(s, 2020, 3, 0), serial_from_ymd(s, 2020, 2, 29));
|
||||
assert_eq!(serial_from_ymd(s, 120, 1, 1), serial_from_ymd(s, 2020, 1, 1));
|
||||
|
||||
@@ -41,7 +41,12 @@ pub fn parse_input(s: &str, sys: DateSystem) -> Parsed {
|
||||
|
||||
/// Numeric text as arithmetic coerces it ("1,000", "50%", "$3", "1/2/2020", "10:00").
|
||||
pub fn parse_number_text(s: &str) -> Option<f64> {
|
||||
parse_numeric(s.trim(), DateSystem::D1900).map(|(n, _)| n)
|
||||
parse_number_text_in(s, DateSystem::D1900)
|
||||
}
|
||||
|
||||
/// Like [`parse_number_text`], with date text read in the given workbook date system.
|
||||
pub fn parse_number_text_in(s: &str, sys: DateSystem) -> Option<f64> {
|
||||
parse_numeric(s.trim(), sys).map(|(n, _)| n)
|
||||
}
|
||||
|
||||
fn parse_numeric(t: &str, sys: DateSystem) -> Option<(f64, Option<&'static str>)> {
|
||||
@@ -252,10 +257,14 @@ fn parse_date(t: &str, sys: DateSystem) -> Option<(f64, &'static str)> {
|
||||
// "Mar 4, 2026" / "March 4 2026" / "4 March 2026"
|
||||
let words: Vec<&str> = t.split([' ', ',']).filter(|w| !w.is_empty()).collect();
|
||||
if words.len() == 3 {
|
||||
// Two-digit years follow the same 2029 rule as the dashed forms above.
|
||||
let expand = |w: &str, y: i64| if w.len() <= 2 { if y < 30 { 2000 + y } else { 1900 + y } } else { y };
|
||||
if let (Some(m), Some(d), Some(y)) = (month_from_name(words[0]), num(words[1]), num(words[2])) {
|
||||
let y = expand(words[2], y);
|
||||
return valid_ymd(sys, y, m as i64, d).map(|s| (s, "d-mmm-yy"));
|
||||
}
|
||||
if let (Some(d), Some(m), Some(y)) = (num(words[0]), month_from_name(words[1]), num(words[2])) {
|
||||
let y = expand(words[2], y);
|
||||
return valid_ymd(sys, y, m as i64, d).map(|s| (s, "d-mmm-yy"));
|
||||
}
|
||||
}
|
||||
@@ -317,6 +326,16 @@ mod tests {
|
||||
parse_input(s, DateSystem::D1900)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn month_name_short_year() {
|
||||
for s in ["Mar 4, 26", "Mar 4 26", "March 4 26", "4 March 26", "Mar 4, 2026"] {
|
||||
assert_eq!(p(s).value, Value::Number(46085.0), "{s}");
|
||||
}
|
||||
assert_eq!(p("Mar 4, 99").value, p("Mar 4, 1999").value);
|
||||
assert_eq!(p("Mar 4, 1776").value, Value::text("Mar 4, 1776"));
|
||||
assert_eq!(p("Feb 30, 26").value, Value::text("Feb 30, 26"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers() {
|
||||
assert_eq!(p("42").value, Value::Number(42.0));
|
||||
|
||||
@@ -224,13 +224,17 @@ impl Value {
|
||||
/// Coercion to a number as arithmetic does it: empty = 0, TRUE = 1, numeric text parses
|
||||
/// (including dates, times, percentages and currency), other text = `#VALUE!`.
|
||||
pub fn to_number(&self) -> Result<f64, CellError> {
|
||||
self.to_number_in(crate::DateSystem::D1900)
|
||||
}
|
||||
/// [`Value::to_number`] with date text read in the given workbook date system.
|
||||
pub fn to_number_in(&self, sys: crate::DateSystem) -> Result<f64, CellError> {
|
||||
match self {
|
||||
Value::Empty => Ok(0.0),
|
||||
Value::Number(n) => Ok(*n),
|
||||
Value::Bool(b) => Ok(if *b { 1.0 } else { 0.0 }),
|
||||
Value::Text(t) => crate::parse::parse_number_text(t).ok_or(CellError::Value),
|
||||
Value::Text(t) => crate::parse::parse_number_text_in(t, sys).ok_or(CellError::Value),
|
||||
Value::Error(e) => Err(*e),
|
||||
Value::Array(a) => a.data.first().map(|v| v.to_number()).unwrap_or(Ok(0.0)),
|
||||
Value::Array(a) => a.data.first().map(|v| v.to_number_in(sys)).unwrap_or(Ok(0.0)),
|
||||
}
|
||||
}
|
||||
/// Coercion to text as `&` does it.
|
||||
|
||||
@@ -184,7 +184,7 @@ impl CfCache {
|
||||
},
|
||||
CfRule::TimePeriod { period, style } => match v.as_f64() {
|
||||
Some(n) => {
|
||||
let today = gridcraft_calc::now_serial().floor();
|
||||
let today = gridcraft_core::date::serial_in_system(wb.date_system, gridcraft_calc::now_serial()).floor();
|
||||
let d = n.floor();
|
||||
let wd = gridcraft_core::date::datetime_from_serial(wb.date_system, today).map(|x| x.weekday as f64).unwrap_or(0.0);
|
||||
let week_start = today - wd;
|
||||
|
||||
+198
-43
@@ -122,7 +122,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
fn col_param(v: Option<&Json>, base: u32) -> Option<u32> {
|
||||
match v? {
|
||||
Json::String(s) => gridcraft_core::letters_to_col(s.trim_start_matches('$')),
|
||||
Json::Number(n) => n.as_u64().map(|o| base + o as u32),
|
||||
Json::Number(n) => n.as_u64().and_then(|o| u32::try_from(o).ok()).and_then(|o| base.checked_add(o)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -198,27 +198,29 @@ fn sort(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
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);
|
||||
let keys: Vec<SortKey> = p
|
||||
.get("keys")
|
||||
.and_then(Json::as_array)
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|k| {
|
||||
let col = if by_cols {
|
||||
k.get("row").and_then(Json::as_u64).map(|r| r as u32 - 1).or_else(|| col_param(k.get("column"), r.start.row))?
|
||||
} else {
|
||||
col_param(k.get("column"), r.start.col)?
|
||||
};
|
||||
Some(SortKey {
|
||||
col,
|
||||
desc: k.get("order").and_then(Json::as_str) == Some("desc"),
|
||||
by: k.get("by").and_then(Json::as_str).unwrap_or("values").to_string(),
|
||||
list: k.get("customList").and_then(Json::as_array).map(|l| l.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut keys: Vec<SortKey> = Vec::new();
|
||||
for k in p.get("keys").and_then(Json::as_array).map(Vec::as_slice).unwrap_or_default() {
|
||||
let col = if by_cols {
|
||||
let row = match k.get("row").and_then(Json::as_u64) {
|
||||
// Worksheet rows are 1-based; 0 (or a value past u32) has no coordinate.
|
||||
Some(n) => {
|
||||
let row = u32::try_from(n).ok().and_then(|n| n.checked_sub(1));
|
||||
Some(row.ok_or_else(|| bad("data.sort", "sort key row must be 1 or greater"))?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
row.or_else(|| col_param(k.get("column"), r.start.row))
|
||||
} else {
|
||||
col_param(k.get("column"), r.start.col)
|
||||
};
|
||||
let Some(col) = col else { continue };
|
||||
keys.push(SortKey {
|
||||
col,
|
||||
desc: k.get("order").and_then(Json::as_str) == Some("desc"),
|
||||
by: k.get("by").and_then(Json::as_str).unwrap_or("values").to_string(),
|
||||
list: k.get("customList").and_then(Json::as_array).map(|l| l.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()),
|
||||
});
|
||||
}
|
||||
if keys.is_empty() {
|
||||
s.ui_requests.push(crate::UiRequest::Dialog("sort".into(), json!({"range": r.a1(), "header": header})));
|
||||
return ok();
|
||||
@@ -454,14 +456,24 @@ fn toggle_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if has {
|
||||
return edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
// Reveal only rows the filter hid; rows hidden by hand or by a collapsed group stay hidden.
|
||||
let filtered: Vec<u32> = sh.rows.keys().copied().filter(|r| sh.is_row_filtered(*r)).collect();
|
||||
sh.autofilter = None;
|
||||
for info in sh.rows.values_mut() {
|
||||
info.hidden = false;
|
||||
for r in filtered {
|
||||
if let Some(info) = sh.rows.get_mut(&r) {
|
||||
info.hidden = false;
|
||||
}
|
||||
}
|
||||
cx.structural = true;
|
||||
Ok(json!({"filter": false}))
|
||||
});
|
||||
}
|
||||
install_filter(s, p)
|
||||
}
|
||||
|
||||
/// 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)?;
|
||||
// A selected header row filters the data below it (#201).
|
||||
if p.get("range").is_none() && r.height() == 1 {
|
||||
@@ -484,11 +496,16 @@ fn filter_by(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let af = match &sh.autofilter {
|
||||
Some(a) => a.clone(),
|
||||
None => {
|
||||
toggle_filter(s, &json!({}))?;
|
||||
install_filter(s, &json!({}))?;
|
||||
s.doc()?.wb.sheet(sheet).and_then(|sh| sh.autofilter.clone()).ok_or_else(|| EngineError::Other("no filter".into()))?
|
||||
}
|
||||
};
|
||||
let col = col_param(p.get("column"), af.range.start.col).ok_or_else(|| bad("data.filterBy", "missing `column`"))?;
|
||||
let col = match col_param(p.get("column"), af.range.start.col) {
|
||||
Some(c) => c,
|
||||
// A number too large to be a column is outside the filter range, not a missing parameter.
|
||||
None if p.get("column").is_some_and(|v| v.as_u64().is_some()) => return Err(bad("data.filterBy", "column outside the filter range")),
|
||||
None => return Err(bad("data.filterBy", "missing `column`")),
|
||||
};
|
||||
if col < af.range.start.col || col > af.range.end.col {
|
||||
return Err(bad("data.filterBy", "column outside the filter range"));
|
||||
}
|
||||
@@ -534,6 +551,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());
|
||||
@@ -570,19 +589,28 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
|
||||
let Some(af) = sh.autofilter.clone() else { return };
|
||||
let r = af.range;
|
||||
let mut hide = Vec::new();
|
||||
// Precompute per-column numbers for top10/average.
|
||||
let mut col_stats: std::collections::HashMap<u32, (Vec<f64>, f64)> = std::collections::HashMap::new();
|
||||
for (off, c) in &af.criteria {
|
||||
// Precompute per-criterion top10 threshold and average once, not per row.
|
||||
let mut col_stats: std::collections::HashMap<usize, (Option<f64>, f64)> = std::collections::HashMap::new();
|
||||
for (i, (off, c)) in af.criteria.iter().enumerate() {
|
||||
if matches!(c, FilterCriterion::Top10 { .. } | FilterCriterion::AboveAverage(_)) {
|
||||
let col = r.start.col + off;
|
||||
let nums: Vec<f64> = (r.start.row + 1..=r.end.row).filter_map(|row| sh.value(CellRef::new(row, col)).as_f64()).collect();
|
||||
let mut nums: Vec<f64> = (r.start.row + 1..=r.end.row).filter_map(|row| sh.value(CellRef::new(row, col)).as_f64()).collect();
|
||||
let avg = if nums.is_empty() { 0.0 } else { nums.iter().sum::<f64>() / nums.len() as f64 };
|
||||
col_stats.insert(*off, (nums, avg));
|
||||
let thr = match c {
|
||||
FilterCriterion::Top10 { bottom, percent, count } => {
|
||||
nums.sort_by(|a, b| if *bottom { a.partial_cmp(b) } else { b.partial_cmp(a) }.unwrap_or(Ordering::Equal));
|
||||
let k = if *percent { ((nums.len() as f64) * (*count as f64) / 100.0).ceil() as usize } else { *count as usize };
|
||||
let k = k.clamp(1, nums.len().max(1));
|
||||
nums.get(k - 1).copied()
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
col_stats.insert(i, (thr, avg));
|
||||
}
|
||||
}
|
||||
for row in r.start.row + 1..=r.end.row {
|
||||
let mut visible = true;
|
||||
for (off, crit) in &af.criteria {
|
||||
for (i, (off, crit)) in af.criteria.iter().enumerate() {
|
||||
let c = CellRef::new(row, r.start.col + off);
|
||||
let v = sh.value(c);
|
||||
let text = crate::display::cell_text(wb, sh, c);
|
||||
@@ -604,18 +632,14 @@ pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
|
||||
None => x,
|
||||
}
|
||||
}
|
||||
FilterCriterion::Top10 { bottom, percent, count } => match (v.as_f64(), col_stats.get(off)) {
|
||||
(Some(n), Some((nums, _))) => {
|
||||
let mut sorted = nums.clone();
|
||||
sorted.sort_by(|a, b| if *bottom { a.partial_cmp(b) } else { b.partial_cmp(a) }.unwrap_or(Ordering::Equal));
|
||||
let k = if *percent { ((sorted.len() as f64) * (*count as f64) / 100.0).ceil() as usize } else { *count as usize };
|
||||
let k = k.clamp(1, sorted.len().max(1));
|
||||
let thr = sorted.get(k - 1).copied().unwrap_or(n);
|
||||
FilterCriterion::Top10 { bottom, .. } => match (v.as_f64(), col_stats.get(&i)) {
|
||||
(Some(n), Some((thr, _))) => {
|
||||
let thr = thr.unwrap_or(n);
|
||||
if *bottom { n <= thr } else { n >= thr }
|
||||
}
|
||||
_ => false,
|
||||
},
|
||||
FilterCriterion::AboveAverage(above) => match (v.as_f64(), col_stats.get(off)) {
|
||||
FilterCriterion::AboveAverage(above) => match (v.as_f64(), col_stats.get(&i)) {
|
||||
(Some(n), Some((_, avg))) => {
|
||||
if *above {
|
||||
n > *avg
|
||||
@@ -896,7 +920,7 @@ fn subtract_range(r: RangeRef, cut: &RangeRef) -> Vec<RangeRef> {
|
||||
}
|
||||
|
||||
/// Removes the cells of `cuts` from every validation, keeping the rest of each rule's ranges.
|
||||
fn remove_validation_cells(validations: &mut Vec<Validation>, cuts: &[RangeRef]) {
|
||||
pub(super) fn remove_validation_cells(validations: &mut Vec<Validation>, cuts: &[RangeRef]) {
|
||||
for dv in validations.iter_mut() {
|
||||
for cut in cuts {
|
||||
dv.ranges = dv.ranges.iter().flat_map(|r| subtract_range(*r, cut)).collect();
|
||||
@@ -1046,6 +1070,18 @@ fn formula_at(f: &str, first: CellRef, at: CellRef) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves the relative references of a rule formula written for `first` so it reads the same cells from `to`
|
||||
/// (pasting a rule); text without relative references (constants, literal lists) stays as typed.
|
||||
pub(super) fn move_rule_formula(f: &str, first: CellRef, to: CellRef) -> String {
|
||||
if first == to {
|
||||
return f.to_string();
|
||||
}
|
||||
let moved = formula_at(f, first, to);
|
||||
let has_relative = gridcraft_formula::parse(f.trim_start_matches('=')).is_ok_and(|e| gridcraft_formula::print(&e) != moved);
|
||||
let eq = if f.starts_with('=') { "=" } else { "" };
|
||||
if has_relative { format!("{eq}{moved}") } else { f.to_string() }
|
||||
}
|
||||
|
||||
fn compare_op(op: CfOperator, n: f64, a: Option<f64>, b: Option<f64>) -> bool {
|
||||
let Some(a) = a else { return false };
|
||||
match op {
|
||||
@@ -1129,6 +1165,7 @@ fn group(s: &mut Session, p: &Json, on: bool) -> Result<Json> {
|
||||
{
|
||||
e.collapsed = false;
|
||||
}
|
||||
cx.structural = true; // ungrouping reveals hidden rows: SUBTOTAL(10x) depends on it
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
@@ -1164,7 +1201,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 {
|
||||
@@ -1178,6 +1215,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> {
|
||||
@@ -1196,7 +1243,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.
|
||||
@@ -1278,6 +1325,38 @@ fn subtotal(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn filter_by_huge_numeric_column_is_rejected_not_overflowed() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "B1", "values": [["Name", "Qty"], ["a", 1], ["b", 2]]})).unwrap();
|
||||
s.execute("data.filter", json!({"range": "B1:C3"})).unwrap();
|
||||
for col in [json!(2), json!(u64::from(u32::MAX)), json!(u64::MAX)] {
|
||||
let err = s.execute("data.filterBy", json!({"column": col, "values": ["a"]})).unwrap_err();
|
||||
assert!(err.to_string().contains("column outside the filter range"), "{col}: {err}");
|
||||
}
|
||||
let ok = s.execute("data.filterBy", json!({"column": 0, "values": ["a"]})).unwrap();
|
||||
assert_eq!(ok["hiddenRows"], json!(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_by_inside_a_table_installs_the_autofilter_and_keeps_the_button() {
|
||||
for sel in [json!({"cell": "A1"}), json!({"cell": "A2"}), json!({"range": "A1:A3"})] {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"]]})).unwrap();
|
||||
s.execute("insert.table", json!({"range": "A1:A3", "header": true})).unwrap();
|
||||
s.execute("selection.set", sel.clone()).unwrap();
|
||||
s.execute("data.filterBy", json!({"column": "A", "values": ["a"]})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sh.autofilter.as_ref().map(|a| a.range.a1()), Some("A1:A3".to_string()), "{sel}");
|
||||
assert!(sh.tables[0].filter_button, "{sel}");
|
||||
assert!(!sh.is_row_hidden(1), "{sel}");
|
||||
assert!(sh.is_row_hidden(2), "{sel}");
|
||||
assert_eq!(sh.value(CellRef::new(2, 0)).display(), "b", "{sel}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_validation_edits_keep_the_rest_of_the_old_rule() {
|
||||
let mut s = Session::new();
|
||||
@@ -1300,6 +1379,70 @@ 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();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"], ["c"]]})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "A10", "values": [["z"]]})).unwrap();
|
||||
s.execute("data.group", json!({"rows": "2:4"})).unwrap();
|
||||
s.execute("data.hideDetail", json!({"rows": "2:4"})).unwrap();
|
||||
s.execute("home.hideRows", json!({"rows": "10:10"})).unwrap();
|
||||
let hidden = |s: &Session| -> Vec<u32> {
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
sh.rows.iter().filter(|(_, l)| l.hidden).map(|(r, _)| *r).collect()
|
||||
};
|
||||
let before = hidden(&s);
|
||||
assert_eq!(before, [1, 2, 3, 9]);
|
||||
s.execute("data.filter", json!({"range": "A1:A4"})).unwrap();
|
||||
s.execute("data.filter", json!({"range": "A1:A4"})).unwrap();
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().autofilter.is_none());
|
||||
assert_eq!(hidden(&s), before);
|
||||
assert_eq!(row_state(&s, 4..=4), [(false, true)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_dropdown_setup_outside_the_table_keeps_manually_hidden_rows() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["a"], ["b"], ["c"]]})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "A10", "values": [["z"]]})).unwrap();
|
||||
s.execute("insert.table", json!({"range": "A1:A4", "header": true})).unwrap();
|
||||
s.execute("home.hideRows", json!({"rows": "3:3"})).unwrap();
|
||||
s.execute("home.hideRows", json!({"rows": "10:10"})).unwrap();
|
||||
s.execute("selection.set", json!({"range": "E1"})).unwrap();
|
||||
// The table filter dropdown installs an AutoFilter on demand by toggling `data.filter` twice.
|
||||
s.execute("data.filter", json!({"range": "A1:A4"})).unwrap();
|
||||
s.execute("data.filter", json!({"range": "A1:A4"})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sh.rows.iter().filter(|(_, l)| l.hidden).map(|(r, _)| *r).collect::<Vec<_>>(), [2, 9]);
|
||||
}
|
||||
|
||||
/// Rows 5:8 grouped, with 6:7 grouped again inside (0-based 4..=7 and 5..=6).
|
||||
fn nested_rows() -> Session {
|
||||
let mut s = Session::new();
|
||||
@@ -1375,6 +1518,18 @@ mod tests {
|
||||
assert_eq!(sc("data.ungroup"), Some("Alt+Shift+Left"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_sort_rejects_row_key_zero_without_panicking() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "2"})).unwrap();
|
||||
for row in [0u64, 1 << 32] {
|
||||
let params = json!({"range": "A1:B2", "header": false, "orientation": "columns", "keys": [{"row": row}]});
|
||||
let err = s.execute("data.sort", params).unwrap_err();
|
||||
assert!(matches!(err, EngineError::BadParams { .. }), "row {row}: {err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split() {
|
||||
assert_eq!(split_delimited("a,b,,c", &[','], false, '"'), ["a", "b", "", "c"]);
|
||||
|
||||
@@ -219,11 +219,8 @@ fn cell_set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Some(c) => c,
|
||||
None => s.doc()?.selection.active,
|
||||
};
|
||||
let input = str_param(p, "input")
|
||||
.or_else(|| str_param(p, "value"))
|
||||
.map(str::to_string)
|
||||
.or_else(|| p.get("value").map(json_to_input))
|
||||
.ok_or_else(|| bad("cell.set", "missing `input` (or `value`)"))?;
|
||||
// Both spellings take a string, number or boolean, as typed (#697).
|
||||
let input = p.get("input").or_else(|| p.get("value")).map(json_to_input).ok_or_else(|| bad("cell.set", "missing `input` (or `value`)"))?;
|
||||
let array = bool_param(p, "array").unwrap_or(false);
|
||||
let d = s.doc()?;
|
||||
if let Some(sh) = d.wb.sheet(sheet)
|
||||
@@ -341,20 +338,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);
|
||||
}
|
||||
@@ -420,14 +428,7 @@ fn selection_move(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) };
|
||||
let sel = &mut d.selection;
|
||||
// Extension moves the far corner, not the active cell.
|
||||
let from = if extend {
|
||||
let r = sel.current();
|
||||
let far_row = if sel.anchor.row == r.start.row { r.end.row } else { r.start.row };
|
||||
let far_col = if sel.anchor.col == r.start.col { r.end.col } else { r.start.col };
|
||||
CellRef::new(far_row, far_col)
|
||||
} else {
|
||||
sel.active
|
||||
};
|
||||
let from = if extend { sel.moving_end() } else { sel.active };
|
||||
// Skip hidden rows/columns and step over merged areas.
|
||||
let mut to = if jmp {
|
||||
jump(sheet, from, dr.signum(), dc.signum())
|
||||
@@ -513,7 +514,7 @@ fn select_col(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
Ok(json!({"selection": d.selection.a1()}))
|
||||
}
|
||||
|
||||
fn go_to(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
pub(super) fn go_to(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let Some(reference) = str_param(p, "reference").or_else(|| str_param(p, "range")) else { return Err(bad("edit.goTo", "missing `reference`")) };
|
||||
// A defined name or table?
|
||||
let d = s.doc()?;
|
||||
@@ -611,7 +612,8 @@ 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() };
|
||||
// Redo goes back to the undone edit's cells, not wherever the user clicked before undoing (#1855).
|
||||
let cur = crate::HistoryEntry { label: e.label.clone(), wb: d.wb.clone(), selection: e.selection.clone() };
|
||||
d.redo.push(cur);
|
||||
label = e.label.clone();
|
||||
d.wb = e.wb;
|
||||
@@ -1111,6 +1113,20 @@ fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// The copied cell's validation rule replaces whatever rule the destination had.
|
||||
if !link && matches!(what, What::All | What::AllExceptBorders | What::Validation) {
|
||||
let rule = src_sheet.validations.iter().find(|dv| dv.ranges.iter().any(|r| r.contains(sc))).cloned();
|
||||
let sh = cx.sheet_mut(dest_sheet)?;
|
||||
super::data::remove_validation_cells(&mut sh.validations, &[RangeRef::new(dest, dest)]);
|
||||
if let Some(mut dv) = rule {
|
||||
// Relative references move with the rule, as pasted formulas do.
|
||||
let first = dv.ranges.first().map_or(sc, |r| r.start);
|
||||
dv.f1 = super::data::move_rule_formula(&dv.f1, first, dest);
|
||||
dv.f2 = dv.f2.map(|f| super::data::move_rule_formula(&f, first, dest));
|
||||
dv.ranges = vec![RangeRef::new(dest, dest)];
|
||||
sh.validations.push(dv);
|
||||
}
|
||||
}
|
||||
match what {
|
||||
What::Comments => {
|
||||
if let Some(cm) = src_sheet.comments.get(&sc).cloned() {
|
||||
@@ -1242,6 +1258,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() {
|
||||
@@ -1280,10 +1299,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)
|
||||
@@ -1583,6 +1616,23 @@ fn begin_edit(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cell_set_takes_numbers_for_input_and_value() {
|
||||
// #697: a JSON number for `input` used to clear the cell instead of entering it.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": 123.5})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A2", "value": 77})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A3", "input": true})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A4", "input": "=A1+A2"})).unwrap();
|
||||
let get = |s: &mut Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap();
|
||||
assert_eq!(get(&mut s, "A1")["value"], json!(123.5));
|
||||
assert_eq!(get(&mut s, "A2")["value"], json!(77.0));
|
||||
assert_eq!(get(&mut s, "A3")["value"], json!(true));
|
||||
assert_eq!(get(&mut s, "A4")["value"], json!(200.5));
|
||||
assert!(s.execute("cell.set", json!({"cell": "A5"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_array_entry() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -701,6 +701,12 @@ fn stash_get(wb: &Workbook, key: &str) -> Json {
|
||||
.unwrap_or(json!({}))
|
||||
}
|
||||
|
||||
/// Like `stash_get`, for the name-keyed lists (views, scripts): a stored value that is not a JSON object is treated as empty.
|
||||
fn stash_map(wb: &Workbook, key: &str) -> Json {
|
||||
let v = stash_get(wb, key);
|
||||
if v.is_object() { v } else { json!({}) }
|
||||
}
|
||||
|
||||
fn stash_set(wb: &mut Workbook, key: &str, v: &Json) {
|
||||
let text = v.to_string();
|
||||
let formula = format!("\"{}\"", text.replace('"', "\"\""));
|
||||
@@ -723,7 +729,7 @@ pub(crate) fn keep_watch_list(from: &Workbook, to: &mut std::sync::Arc<Workbook>
|
||||
|
||||
fn custom_views(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let d = s.doc()?;
|
||||
let mut views = stash_get(&d.wb, VIEWS_NAME);
|
||||
let mut views = stash_map(&d.wb, VIEWS_NAME);
|
||||
if let Some(name) = str_param(p, "save") {
|
||||
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
|
||||
views[name] = json!({"sheet": d.wb.active_sheet, "selection": d.selection.a1(), "zoom": sh.zoom, "freeze": sh.freeze});
|
||||
@@ -743,6 +749,17 @@ fn custom_views(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if let Some(z) = v["zoom"].as_u64() {
|
||||
s.execute("view.zoom", json!({"percent": z}))?;
|
||||
}
|
||||
if let Some(f) = v.get("freeze") {
|
||||
let freeze = match (f[0].as_u64(), f[1].as_u64()) {
|
||||
(Some(r), Some(c)) => u32::try_from(r).ok().zip(u32::try_from(c).ok()),
|
||||
_ => None,
|
||||
};
|
||||
edit(s, |cx| {
|
||||
let si = cx.sheet_index();
|
||||
cx.sheet_mut(si)?.freeze = freeze;
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
} else if let Some(name) = str_param(p, "delete") {
|
||||
if let Some(o) = views.as_object_mut() {
|
||||
o.remove(name);
|
||||
@@ -812,7 +829,7 @@ fn table_resize(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
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();
|
||||
let t = sheet.tables.get_mut(ti).ok_or_else(|| bad("table", "gone"))?;
|
||||
if r.start.row != t.range.start.row {
|
||||
if r.start.row != t.range.start.row || !r.intersects(&t.range) {
|
||||
return Err(EngineError::Other(
|
||||
"The headers must remain in the same row, and the resulting table range must overlap the original table range.".into(),
|
||||
));
|
||||
@@ -821,7 +838,14 @@ fn table_resize(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
for (i, h) in headers.iter().enumerate() {
|
||||
let name = if h.is_empty() { format!("Column{}", i + 1) } else { h.clone() };
|
||||
let existing = t.columns.iter().find(|c| c.name.eq_ignore_ascii_case(&name)).cloned();
|
||||
cols.push(existing.unwrap_or(TableColumn { name, totals: TotalsFn::None, totals_label: None, formula: None }));
|
||||
cols.push(existing.unwrap_or(TableColumn {
|
||||
name,
|
||||
totals: TotalsFn::None,
|
||||
totals_label: None,
|
||||
formula: None,
|
||||
unique_name: None,
|
||||
xml: None,
|
||||
}));
|
||||
}
|
||||
t.columns = cols;
|
||||
t.range = r;
|
||||
@@ -938,7 +962,7 @@ fn new_script(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
return Err(bad("automate.newScript", "`commands` must be an array"));
|
||||
}
|
||||
edit(s, |cx| {
|
||||
let mut all = stash_get(&cx.wb, SCRIPTS_NAME);
|
||||
let mut all = stash_map(&cx.wb, SCRIPTS_NAME);
|
||||
all[name.as_str()] = cmds.clone();
|
||||
stash_set(&mut cx.wb, SCRIPTS_NAME, &all);
|
||||
Ok(json!({"name": name}))
|
||||
@@ -977,7 +1001,7 @@ fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
fn delete_script(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("automate.deleteScript", "missing `name`"))?.to_string();
|
||||
edit(s, |cx| {
|
||||
let mut all = stash_get(&cx.wb, SCRIPTS_NAME);
|
||||
let mut all = stash_map(&cx.wb, SCRIPTS_NAME);
|
||||
if let Some(o) = all.as_object_mut() {
|
||||
o.remove(&name);
|
||||
}
|
||||
@@ -1052,8 +1076,13 @@ fn toggle_checkbox(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Copy of the params that is always a JSON object, so keys can be assigned; any other JSON value counts as no params (like null).
|
||||
fn object_params(p: &Json) -> Json {
|
||||
Json::Object(p.as_object().cloned().unwrap_or_default())
|
||||
}
|
||||
|
||||
fn theme_colors(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let mut params = p.clone();
|
||||
let mut params = object_params(p);
|
||||
if params.get("name").is_none() {
|
||||
params["name"] = json!(s.doc()?.wb.theme.name.clone());
|
||||
}
|
||||
@@ -1074,7 +1103,7 @@ fn background(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Ok(Json::Null)
|
||||
});
|
||||
}
|
||||
let mut params = p.clone();
|
||||
let mut params = object_params(p);
|
||||
params["alt"] = json!("Sheet background");
|
||||
params["at"] = json!("A1");
|
||||
s.execute("insert.picture", params)
|
||||
@@ -1090,7 +1119,7 @@ fn draw_border(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
"outline" => "outside",
|
||||
_ => "all",
|
||||
};
|
||||
let mut params = p.clone();
|
||||
let mut params = object_params(p);
|
||||
params["preset"] = json!(preset);
|
||||
s.execute("home.borders", params)
|
||||
}
|
||||
|
||||
@@ -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,7 +56,8 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
return ok();
|
||||
};
|
||||
let encrypted = gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Encrypted;
|
||||
let (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);
|
||||
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);
|
||||
@@ -75,7 +76,11 @@ fn open(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
{
|
||||
s.close_document(0);
|
||||
}
|
||||
let i = s.add_document(DocState::new(wb, path, title));
|
||||
let mut doc = DocState::new(wb, path, title);
|
||||
if crate::io::FileKind::from_path(&name).is_none() && gridcraft_xlsx::sniff(&bytes) == gridcraft_xlsx::Format::Xlsx {
|
||||
doc.content_kind = Some(crate::io::FileKind::Xlsx);
|
||||
}
|
||||
let i = s.add_document(doc);
|
||||
// The decrypted-workbook notice is for the info bar, not a message box.
|
||||
let shown: Vec<&str> = warnings.iter().map(String::as_str).filter(|w| *w != crate::io::DECRYPTED_NOTICE).collect();
|
||||
if !shown.is_empty() {
|
||||
@@ -105,11 +110,18 @@ fn save(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
return super::print::export_pdf(s, &json!({"path": path}));
|
||||
}
|
||||
let d = s.doc_mut()?;
|
||||
let bytes = crate::io::save_bytes(&d.wb, &path)?;
|
||||
// The document's own file keeps the format its content had when opened; any other path
|
||||
// goes by its extension. Relative paths to linked workbooks are rewritten for the new folder.
|
||||
let kind = crate::io::FileKind::from_path(&path).or(d.content_kind.filter(|_| d.path.as_deref() == Some(path.as_str())));
|
||||
let rebased = crate::io::source_dir(&path).and_then(|f| gridcraft_xlsx::rebase_links(&d.wb, &f));
|
||||
let wb = rebased.as_ref().unwrap_or(&d.wb);
|
||||
let bytes = match kind {
|
||||
Some(kind) => crate::io::save_bytes_as(wb, &path, kind)?,
|
||||
None => crate::io::save_bytes(wb, &path)?,
|
||||
};
|
||||
crate::io::write_file(&path, &bytes)?;
|
||||
let kind = crate::io::FileKind::from_path(&path).unwrap_or(crate::io::FileKind::Xlsx);
|
||||
// Only a full-fidelity format clears the dirty flag and becomes the document's file.
|
||||
if matches!(kind, crate::io::FileKind::Xlsx | crate::io::FileKind::Json) {
|
||||
if matches!(kind, Some(crate::io::FileKind::Xlsx | crate::io::FileKind::Json)) {
|
||||
d.saved = d.wb.clone();
|
||||
d.path = Some(path.clone());
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Forecast Sheet",
|
||||
["Data", "Forecast"],
|
||||
None,
|
||||
"{timeline?: \"A2:A25\", values?: \"B2:B25\" (default: the first two columns of `range`, the selection or the current region; a text first cell is the header), end?: timeline value or date text | periods?: n (default: a third of the history, at least 1; ≤10,000 and history × periods ≤ 500,000; history ≤ 10,000 rows), confidenceInterval?: bool (default true), confidence?: 0.95 or 95, seasonality?: \"auto\" | 0 (none) | 2–8,760, fillMissing?: interpolate|zeros, aggregate?: average|count|counta|max|median|min|sum, statistics?: bool, chart?: line|column} → {sheet, table, chart, history, periods}. Inserts a sheet before the active one: timeline, values, Forecast (FORECAST.ETS), Lower/Upper Confidence Bound (FORECAST.ETS.CONFINT), optional FORECAST.ETS.STAT table, and a chart.",
|
||||
"{timeline?: \"A2:A25\", values?: \"B2:B25\" (default: the first two columns of `range`, the selection or the current region; a text first cell is the header), end?: timeline value or date text | periods?: n (default: a third of the history, at least 1; ≤10,000 and history × periods ≤ 500,000; history ≤ 10,000 rows), forecastStart?: a timeline value or date text on the history, before its last point (hindcast: the model is fit to the points before it, the forecast columns start there and run over the actuals; default and the last point: forecast after the history), confidenceInterval?: bool (default true), confidence?: 0.95 or 95, seasonality?: \"auto\" | 0 (none) | 2–8,760, fillMissing?: interpolate|zeros, aggregate?: average|count|counta|max|median|min|sum, statistics?: bool, chart?: line|column} → {sheet, table, chart, history, periods}. Inserts a sheet before the active one: timeline, values, Forecast (FORECAST.ETS), Lower/Upper Confidence Bound (FORECAST.ETS.CONFINT), optional FORECAST.ETS.STAT table, and a chart.",
|
||||
has_doc,
|
||||
forecast_sheet
|
||||
)]
|
||||
@@ -223,13 +223,39 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if !rows.iter().any(|r| matches!(r.1, Value::Number(_))) {
|
||||
return Err(EngineError::Other("There are no values to forecast.".into()));
|
||||
}
|
||||
let end = match p.get("end") {
|
||||
Some(Json::Number(n)) => n.as_f64(),
|
||||
Some(Json::String(t)) if !t.trim().is_empty() => match gridcraft_core::parse::parse_input(t.trim(), sys).value {
|
||||
Value::Number(end) => Some(end),
|
||||
_ => return Err(bad(CMD, format!("`end` is not a number or a date: `{t}`"))),
|
||||
},
|
||||
_ => None,
|
||||
let time_param = |key: &str| -> Result<Option<f64>> {
|
||||
match p.get(key) {
|
||||
Some(Json::Number(n)) => Ok(n.as_f64()),
|
||||
Some(Json::String(t)) if !t.trim().is_empty() => match gridcraft_core::parse::parse_input(t.trim(), sys).value {
|
||||
Value::Number(x) => Ok(Some(x)),
|
||||
_ => Err(bad(CMD, format!("`{key}` is not a number or a date: `{t}`"))),
|
||||
},
|
||||
None | Some(Json::Null) => Ok(None),
|
||||
Some(Json::String(_)) => Ok(None),
|
||||
Some(_) => Err(bad(CMD, format!("`{key}` is not a number or a date"))),
|
||||
}
|
||||
};
|
||||
let end = time_param("end")?;
|
||||
// Forecast Start: the index (into `rows`) of the first forecast point, or `rows.len()` for the
|
||||
// usual forecast after the history.
|
||||
let fit = match time_param("forecastStart")? {
|
||||
None => rows.len(),
|
||||
Some(start) => {
|
||||
let last_t = times.last().copied().unwrap_or(f64::NAN);
|
||||
let tol = |a: f64, b: f64| (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0);
|
||||
if tol(start, last_t) || timeline.after_last(1).is_some_and(|next| tol(start, next)) {
|
||||
rows.len()
|
||||
} else {
|
||||
let i = times
|
||||
.iter()
|
||||
.position(|t| tol(*t, start))
|
||||
.ok_or_else(|| bad(CMD, "`forecastStart` is not on the timeline (it is a point of the history, before its last point)"))?;
|
||||
if rows.get(..i).is_none_or(|h| h.iter().filter(|r| matches!(r.1, Value::Number(_))).count() < 2) {
|
||||
return Err(EngineError::Other("The forecast start needs at least two values before it to fit the forecast.".into()));
|
||||
}
|
||||
i
|
||||
}
|
||||
}
|
||||
};
|
||||
let cap = max_periods(rows.len());
|
||||
let periods = match end {
|
||||
@@ -252,8 +278,9 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
// Columns: A timeline, B values, C forecast, D/E bounds.
|
||||
let n = rows.len() as u32;
|
||||
let last = n + 1; // 1-based row of the last history point
|
||||
let fit_last = fit as u32 + 1; // 1-based row of the last point the model is fit to
|
||||
let end_row = last + periods; // 1-based row of the last forecast
|
||||
let hist = |col: u32| format!("${0}$2:${0}${last}", col_letters(col));
|
||||
let hist = |col: u32| format!("${0}$2:${0}${fit_last}", col_letters(col));
|
||||
let mut tail = format!("{},{}", opts.seasonality, opts.fill);
|
||||
if let Some(a) = opts.aggregate {
|
||||
tail.push_str(&format!(",{a}"));
|
||||
@@ -305,22 +332,30 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
put(&mut sheet, r, 1, v.clone(), vstyle);
|
||||
}
|
||||
}
|
||||
// The last history point starts the forecast lines, so they join the values.
|
||||
formula(&mut sheet, n, 2, format!("=B{last}"), vstyle);
|
||||
// The last point the model is fit to starts the forecast lines, so they join the values.
|
||||
let join = fit_last - 1; // 0-based row
|
||||
formula(&mut sheet, join, 2, format!("=B{fit_last}"), vstyle);
|
||||
if opts.confidence.is_some() {
|
||||
formula(&mut sheet, n, 3, format!("=C{last}"), vstyle);
|
||||
formula(&mut sheet, n, 4, format!("=C{last}"), vstyle);
|
||||
formula(&mut sheet, join, 3, format!("=C{fit_last}"), vstyle);
|
||||
formula(&mut sheet, join, 4, format!("=C{fit_last}"), vstyle);
|
||||
}
|
||||
let (tl, vl) = (hist(0), hist(1));
|
||||
let forecast_row = |sheet: &mut Sheet, r: u32| {
|
||||
formula(sheet, r - 1, 2, format!("=FORECAST.ETS(A{r},{vl},{tl},{tail})"), vstyle);
|
||||
if let Some(c) = opts.confidence {
|
||||
let ci = format!("FORECAST.ETS.CONFINT(A{r},{vl},{tl},{c},{tail})");
|
||||
formula(sheet, r - 1, 3, format!("=C{r}-{ci}"), vstyle);
|
||||
formula(sheet, r - 1, 4, format!("=C{r}+{ci}"), vstyle);
|
||||
}
|
||||
};
|
||||
// Hindcast: forecasts over the actuals from the start to the last history point.
|
||||
for r in (fit_last + 1)..=last {
|
||||
forecast_row(&mut sheet, r);
|
||||
}
|
||||
for (k, t) in future.iter().enumerate() {
|
||||
let r = last + k as u32 + 1; // 1-based
|
||||
put(&mut sheet, r - 1, 0, Value::Number(*t), tstyle);
|
||||
formula(&mut sheet, r - 1, 2, format!("=FORECAST.ETS(A{r},{vl},{tl},{tail})"), vstyle);
|
||||
if let Some(c) = opts.confidence {
|
||||
let ci = format!("FORECAST.ETS.CONFINT(A{r},{vl},{tl},{c},{tail})");
|
||||
formula(&mut sheet, r - 1, 3, format!("=C{r}-{ci}"), vstyle);
|
||||
formula(&mut sheet, r - 1, 4, format!("=C{r}+{ci}"), vstyle);
|
||||
}
|
||||
forecast_row(&mut sheet, r);
|
||||
}
|
||||
if opts.statistics {
|
||||
put(&mut sheet, 0, stats_col, Value::text("Statistic"), bold);
|
||||
@@ -351,7 +386,10 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
range,
|
||||
header_row: true,
|
||||
totals_row: false,
|
||||
columns: headers.iter().map(|h| TableColumn { name: h.clone(), totals: TotalsFn::None, totals_label: None, formula: None }).collect(),
|
||||
columns: headers
|
||||
.iter()
|
||||
.map(|h| TableColumn { name: h.clone(), totals: TotalsFn::None, totals_label: None, formula: None, unique_name: None, xml: None })
|
||||
.collect(),
|
||||
style: "TableStyleMedium2".into(),
|
||||
banded_rows: true,
|
||||
banded_cols: false,
|
||||
@@ -359,6 +397,8 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
last_col: false,
|
||||
filter_button: true,
|
||||
filter: vec![],
|
||||
table_type: None,
|
||||
connection_id: None,
|
||||
});
|
||||
let q = gridcraft_formula::quote_sheet(&sheet_name);
|
||||
let col_ref = |c: u32| format!("{q}!${0}$2:${0}${end_row}", col_letters(c));
|
||||
@@ -407,7 +447,7 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if let Ok(d) = s.doc_mut() {
|
||||
d.sheet_selections.insert(index.min(d.sheet_selections.len()), crate::Selection::default());
|
||||
}
|
||||
Ok(json!({"sheet": sheet_name, "table": table_name, "chart": chart_id, "history": n, "periods": periods}))
|
||||
Ok(json!({"sheet": sheet_name, "table": table_name, "chart": chart_id, "history": n, "periods": periods, "fitted": fit}))
|
||||
}
|
||||
|
||||
/// Forecast rows needed to reach `end` (rounded up to whole steps).
|
||||
@@ -545,4 +585,39 @@ mod tests {
|
||||
s.execute("cell.set", json!({"cell": "A5", "input": "4.5"})).unwrap();
|
||||
assert!(s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forecast_start_hindcasts_over_the_actuals() {
|
||||
let mut s = session_with_history(); // t = 1..16 in A2:A17
|
||||
let r = s.execute("data.forecastSheet", json!({"range": "A1:B17", "forecastStart": 13, "periods": 2, "confidenceInterval": false})).unwrap();
|
||||
assert_eq!(r["fitted"], 12);
|
||||
// Fit to t = 1..12 (rows 2..13); the line joins at row 13 and forecasts rows 14..19.
|
||||
assert_eq!(formula(&s, 0, "C13"), "B13");
|
||||
assert_eq!(value(&s, 0, "C13"), value(&s, 0, "B13"));
|
||||
assert_eq!(formula(&s, 0, "C14"), "FORECAST.ETS(A14,$B$2:$B$13,$A$2:$A$13,1,1)");
|
||||
assert_eq!(formula(&s, 0, "C19"), "FORECAST.ETS(A19,$B$2:$B$13,$A$2:$A$13,1,1)");
|
||||
// The exact seasonal series is recovered, and the actual is still shown next to it.
|
||||
let c14 = value(&s, 0, "C14").as_f64().unwrap();
|
||||
assert!((c14 - 39.0).abs() < 1e-6, "{c14}"); // t = 13: 10 + 26 + 3
|
||||
assert!(value(&s, 0, "B14").as_f64().is_some());
|
||||
// Start at the last point or one step later: as without forecastStart.
|
||||
for start in [json!(16), json!(17)] {
|
||||
let r = s.execute("data.forecastSheet", json!({"range": "Sheet1!A1:B17", "forecastStart": start, "periods": 2})).unwrap();
|
||||
assert_eq!(r["fitted"], 16);
|
||||
}
|
||||
assert_eq!(formula(&s, 0, "C18"), "FORECAST.ETS(A18,$B$2:$B$17,$A$2:$A$17,1,1)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forecast_start_invalid_values_are_errors() {
|
||||
let mut s = session_with_history();
|
||||
let sheets = s.doc().unwrap().wb.sheets.len();
|
||||
for bad in [json!(0), json!(2), json!(3.5), json!(18), json!(1000), json!("abc"), json!([1]), json!(1e308)] {
|
||||
assert!(s.execute("data.forecastSheet", json!({"range": "A1:B17", "forecastStart": bad})).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().wb.sheets.len(), sheets);
|
||||
// Blank or null behaves like no start; empty params unchanged.
|
||||
let r = s.execute("data.forecastSheet", json!({"range": "A1:B17", "forecastStart": ""})).unwrap();
|
||||
assert_eq!(r["fitted"], 16);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,10 +237,12 @@ pub(crate) fn apply_style(s: &mut Session, p: &Json, f: impl Fn(&mut Style)) ->
|
||||
// Existing cells in those lines.
|
||||
let cells: Vec<(CellRef, StyleId)> =
|
||||
cx.wb.sheet(sheet).map(|sh| sh.cells.iter_range(*r).map(|(c, x)| (c, x.style)).collect()).unwrap_or_default();
|
||||
for (c, st) in cells {
|
||||
let new = *cache.entry(st).or_insert_with(|| cx.wb.styles.derive(st, &f));
|
||||
cx.sheet_mut(sheet)?.set_style(c, new);
|
||||
for (c, st) in &cells {
|
||||
let new = *cache.entry(*st).or_insert_with(|| cx.wb.styles.derive(*st, &f));
|
||||
cx.sheet_mut(sheet)?.set_style(*c, new);
|
||||
}
|
||||
// Formatting touched these cells: re-fit their rows (Excel auto-fits).
|
||||
cx.fit_rows.extend(cells.iter().map(|(c, _)| (sheet, c.row)));
|
||||
continue;
|
||||
}
|
||||
if r.count() > 4_000_000 {
|
||||
@@ -574,6 +576,16 @@ fn merge(s: &mut Session, p: &Json, how: &str) -> Result<Json> {
|
||||
let m = RangeRef::new(CellRef::new(row, r.start.col), CellRef::new(row, r.end.col));
|
||||
if !m.is_single() {
|
||||
sh.merges.push(m);
|
||||
// As below: only the first value of each row survives.
|
||||
let others: Vec<CellRef> = sh.cells.iter_range(m).map(|(c, _)| c).filter(|c| *c != m.start).collect();
|
||||
for c in others {
|
||||
if let Some(mut cell) = sh.cells.get(c).cloned() {
|
||||
cell.value = gridcraft_core::Value::Empty;
|
||||
cell.formula = None;
|
||||
sh.set_cell(c, cell);
|
||||
}
|
||||
touched.push((sheet, c));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -953,6 +965,7 @@ fn row_height(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
e.custom = true;
|
||||
}
|
||||
}
|
||||
cx.structural = true; // setting a height shows or hides rows: SUBTOTAL(10x) depends on it
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
@@ -992,9 +1005,7 @@ pub fn chars_to_points(c: f64) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn points_to_chars(px: f64) -> f64 {
|
||||
if px <= 12.0 { (px / 12.0 * 100.0).round() / 100.0 } else { ((px - 5.0) / 7.0 * 100.0 + 0.5).trunc() / 100.0 }
|
||||
}
|
||||
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).
|
||||
@@ -1069,6 +1080,7 @@ fn autofit_rows(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
cx.fit_rows.extend((a..=b).map(|r| (si, r)));
|
||||
}
|
||||
cx.structural = true; // autofit reveals hidden rows: SUBTOTAL(10x) depends on it
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
@@ -1112,6 +1124,19 @@ fn hide(s: &mut Session, p: &Json, rows: bool, on: bool) -> Result<Json> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn merge_across_keeps_the_first_value_of_each_row() {
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2], [3, 4]]})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "D1", "input": "=SUM(A1:B2)"})).unwrap();
|
||||
s.execute("home.mergeAcross", json!({"range": "A1:B2"})).unwrap();
|
||||
let v = |s: &mut crate::Session, a: &str| s.execute("cell.get", json!({"cell": a})).unwrap()["value"].clone();
|
||||
assert_eq!(v(&mut s, "B1"), Json::Null);
|
||||
assert_eq!(v(&mut s, "B2"), Json::Null);
|
||||
assert_eq!(v(&mut s, "D1"), json!(4.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimal_steps() {
|
||||
let v = gridcraft_core::Value::Number(1.5);
|
||||
|
||||
@@ -229,7 +229,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, '_' | '.' | '\\' | '?'))
|
||||
@@ -610,4 +610,18 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
+103
-14
@@ -153,10 +153,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Link",
|
||||
["Insert", "Links"],
|
||||
Some("Cmd+K"),
|
||||
"{cell?, target: \"https://…\" | \"Sheet2!A1\" | \"mailto:…\", text?, tooltip?, remove?: bool}",
|
||||
"{cell?, target: \"https://…\" | \"Book.xlsx\" | \"Sheet2!A1\" | \"mailto:…\", text?, tooltip?, remove?: bool}",
|
||||
has_doc,
|
||||
insert_link
|
||||
),
|
||||
cmd!(
|
||||
"link.open",
|
||||
"Open Hyperlink",
|
||||
[],
|
||||
None,
|
||||
"{url?: \"https://…\" | \"mailto:…\" | \"Sheet2!A1\", cell?} (default: the cell's link); only http, https and mailto are opened, any other scheme is an error; the UI asks before opening",
|
||||
has_doc,
|
||||
open_link
|
||||
),
|
||||
cmd!("review.newNote", "New Note", ["Review", "Notes"], Some("Shift+F2"), "{cell?, text, author?}", has_doc, |s, p| comment(s, p, false)),
|
||||
cmd!("review.newComment", "New Comment", ["Review", "Comments"], Some("Cmd+Shift+F2"), "{cell?, text, author?}", has_doc, |s, p| comment(
|
||||
s, p, true
|
||||
@@ -240,7 +249,7 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let name = str_param(p, "name")
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| (1..).map(|n| format!("Table{n}")).find(|n| d.wb.table(n).is_none()).unwrap_or_else(|| "Table".into()));
|
||||
if name.is_empty() || name.contains(' ') || name.chars().next().is_some_and(|c| c.is_ascii_digit()) || CellRef::parse(&name).is_some() {
|
||||
if !valid_table_name(&name) {
|
||||
return Err(EngineError::Other("The name that you entered is not valid.".into()));
|
||||
}
|
||||
if d.wb.table(&name).is_some() || d.wb.names.iter().any(|n| n.name.eq_ignore_ascii_case(&name)) {
|
||||
@@ -310,7 +319,10 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
range: r,
|
||||
header_row: true,
|
||||
totals_row: false,
|
||||
columns: names.into_iter().map(|n| TableColumn { name: n, totals: TotalsFn::None, totals_label: None, formula: None }).collect(),
|
||||
columns: names
|
||||
.into_iter()
|
||||
.map(|n| TableColumn { name: n, totals: TotalsFn::None, totals_label: None, formula: None, unique_name: None, xml: None })
|
||||
.collect(),
|
||||
style,
|
||||
banded_rows: true,
|
||||
banded_cols: false,
|
||||
@@ -318,6 +330,8 @@ fn insert_table(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
last_col: false,
|
||||
filter_button: true,
|
||||
filter: vec![],
|
||||
table_type: None,
|
||||
connection_id: None,
|
||||
});
|
||||
cx.structural = true;
|
||||
Ok(json!({"table": name, "range": r.a1()}))
|
||||
@@ -349,6 +363,9 @@ fn table_flag(s: &mut Session, p: &Json, which: &str) -> Result<Json> {
|
||||
"totals" => {
|
||||
let v = on.unwrap_or(!t.totals_row);
|
||||
if v != t.totals_row {
|
||||
if v && t.range.end.row.saturating_add(1) >= MAX_ROWS {
|
||||
return Err(EngineError::Other("There's no room below the table for the total row.".into()));
|
||||
}
|
||||
t.totals_row = v;
|
||||
if v {
|
||||
t.range.end.row += 1;
|
||||
@@ -448,14 +465,20 @@ 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 `\`, 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.chars().count() <= 255
|
||||
&& name.starts_with(|c: char| c.is_alphabetic() || c == '_' || c == '\\')
|
||||
&& CellRef::parse(name).is_none()
|
||||
&& name.chars().enumerate().all(|(i, c)| c.is_alphanumeric() || c == '_' || c == '.' || (i == 0 && c == '\\'))
|
||||
}
|
||||
|
||||
fn table_rename(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (si, ti) = find_table(s, p)?;
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("table.rename", "missing `name`"))?.to_string();
|
||||
if name.is_empty()
|
||||
|| name.contains(' ')
|
||||
|| name.chars().next().is_some_and(|c| c.is_ascii_digit())
|
||||
|| gridcraft_core::CellRef::parse(&name).is_some()
|
||||
{
|
||||
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)) {
|
||||
@@ -470,6 +493,8 @@ fn table_rename(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
for i in 0..cx.wb.sheets.len() {
|
||||
let keys: Vec<(CellRef, std::sync::Arc<Formula>)> =
|
||||
cx.wb.sheets.get(i).map(|s| s.cells.iter().filter_map(|(c, x)| x.formula.clone().map(|f| (c, f))).collect()).unwrap_or_default();
|
||||
// A bare name that is also a defined name resolves to the defined name, so it is not the table.
|
||||
let shadowed = cx.wb.name(&old, i).is_some();
|
||||
let Some(sh) = cx.wb.sheet_mut(i) else { continue };
|
||||
for (c, f) in keys {
|
||||
let Some(e) = f.expr() else { continue };
|
||||
@@ -478,7 +503,7 @@ fn table_rename(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
st.table = name.clone();
|
||||
gridcraft_formula::Expr::Struct(st)
|
||||
}
|
||||
gridcraft_formula::Expr::Name(n) if n.eq_ignore_ascii_case(&old) => gridcraft_formula::Expr::Name(name.clone()),
|
||||
gridcraft_formula::Expr::Name(n) if !shadowed && n.eq_ignore_ascii_case(&old) => gridcraft_formula::Expr::Name(name.clone()),
|
||||
o => o,
|
||||
});
|
||||
if let Some(cell) = sh.cells.get_mut(c) {
|
||||
@@ -1156,6 +1181,7 @@ fn insert_link(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
};
|
||||
let text = str_param(p, "text").map(str::to_string);
|
||||
let tooltip = str_param(p, "tooltip").map(str::to_string);
|
||||
let external = links_outside(&s.doc()?.wb, sheet, &target);
|
||||
edit(s, |cx| {
|
||||
let link_style = cx.wb.styles.derive(cx.wb.sheet(sheet).map(|sh| sh.style_id(at)).unwrap_or_default(), |st| {
|
||||
st.font.color = Color::Theme(10, 0);
|
||||
@@ -1167,12 +1193,55 @@ fn insert_link(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
} else {
|
||||
sh.set_style(at, link_style);
|
||||
}
|
||||
sh.hyperlinks.insert(at, Hyperlink { target: target.clone(), tooltip: tooltip.clone() });
|
||||
sh.hyperlinks.insert(at, Hyperlink { target: target.clone(), tooltip: tooltip.clone(), external });
|
||||
cx.touch(sheet, at);
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
||||
/// Follows a link: a reference selects it, an `http`/`https`/`mailto` address is handed to the
|
||||
/// host (`UiRequest::OpenUrl`); anything else is refused ([`crate::links::classify`]). Opens no
|
||||
/// dialog; the interactive click asks for confirmation first, in the UI.
|
||||
fn open_link(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let target = match str_param(p, "url").or_else(|| str_param(p, "target")) {
|
||||
Some(t) => t.to_string(),
|
||||
None => {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
|
||||
let Some(h) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.hyperlinks.get(&at)) else {
|
||||
return Err(EngineError::Other("There's no hyperlink in this cell.".into()));
|
||||
};
|
||||
h.target.clone()
|
||||
}
|
||||
};
|
||||
match crate::links::classify(&target) {
|
||||
crate::links::LinkKind::Internal(r) => super::edit::go_to(s, &json!({"reference": r.trim_start_matches('#')})),
|
||||
crate::links::LinkKind::External(u) => {
|
||||
s.ui_requests.push(crate::UiRequest::OpenUrl(u.clone()));
|
||||
Ok(json!({"opened": u}))
|
||||
}
|
||||
crate::links::LinkKind::Refused(why) => Err(EngineError::Other(why)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sorts a link target as the two pages of Excel's Insert Hyperlink dialog do: "Place in This
|
||||
/// Document" is a sheet reference, a defined name or `#…`; anything else ("Existing File or Web
|
||||
/// Page") is outside the workbook and saved as a relationship.
|
||||
fn links_outside(wb: &Workbook, sheet: usize, target: &str) -> bool {
|
||||
let t = target.trim();
|
||||
if t.starts_with('#') || wb.name(t, sheet).is_some() || wb.table(t).is_some() {
|
||||
return false;
|
||||
}
|
||||
let (on, body) = split_sheet(t);
|
||||
// Sheet names can't hold `: \ / ? * [ ]`, so `https://…!A1` or `C:\…!A1` is no sheet reference,
|
||||
// and `Book.xlsx#Sheet1!A1` is a place in another file unless a sheet has that name.
|
||||
let on_sheet = on.is_none_or(|name| {
|
||||
wb.sheets.iter().any(|s| s.name.eq_ignore_ascii_case(&name))
|
||||
|| (!name.is_empty() && !name.contains([':', '\\', '/', '?', '*', '[', ']', '#']))
|
||||
});
|
||||
!(on_sheet && RangeRef::parse(body).is_some())
|
||||
}
|
||||
|
||||
fn comment(s: &mut Session, p: &Json, threaded: bool) -> Result<Json> {
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let at = cell_param_on(s, p, "cell", sheet)?.unwrap_or(s.doc()?.selection.active);
|
||||
@@ -1420,20 +1489,20 @@ fn manage_cf(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
edit(s, |cx| {
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
let n = sh.cond_formats.len();
|
||||
// The count is taken again after each step: a delete shortens the list.
|
||||
if let Some(i) = u32_param(p, "delete").map(|v| v as usize)
|
||||
&& i < n
|
||||
&& i < sh.cond_formats.len()
|
||||
{
|
||||
sh.cond_formats.remove(i);
|
||||
}
|
||||
if let Some(i) = u32_param(p, "moveUp").map(|v| v as usize)
|
||||
&& i > 0
|
||||
&& i < n
|
||||
&& i < sh.cond_formats.len()
|
||||
{
|
||||
sh.cond_formats.swap(i, i - 1);
|
||||
}
|
||||
if let Some(i) = u32_param(p, "moveDown").map(|v| v as usize)
|
||||
&& i + 1 < n
|
||||
&& i + 1 < sh.cond_formats.len()
|
||||
{
|
||||
sh.cond_formats.swap(i, i + 1);
|
||||
}
|
||||
@@ -1466,3 +1535,23 @@ fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Ok(json!({"shape": id}))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn manage_rules_delete_then_move() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
for v in ["1", "2"] {
|
||||
let rule = json!({"type": "cellIs", "operator": "greaterThan", "value": v, "preset": "lightRedFill"});
|
||||
s.execute("home.conditionalFormat", json!({"range": "A1:A3", "rule": rule})).unwrap();
|
||||
}
|
||||
// Deleting first left two rules' worth of indices for the move: it panicked.
|
||||
s.execute("home.manageRules", json!({"delete": 0, "moveUp": 1})).unwrap();
|
||||
s.execute("home.manageRules", json!({"delete": 0, "moveDown": 0})).unwrap();
|
||||
let rules = s.execute("home.manageRules", json!({})).unwrap();
|
||||
assert_eq!(rules["rules"].as_array().map(Vec::len), Some(0));
|
||||
}
|
||||
}
|
||||
|
||||
+297
-22
@@ -87,7 +87,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Move Field",
|
||||
[],
|
||||
None,
|
||||
"{pivot?, field, from: rows|columns|values|filters, to: rows|columns|values|filters, position?: index, replace?}",
|
||||
"{pivot?, field (\"Σ Values\" moves the value fields between rows and columns; in columns it stays after the last field), from: rows|columns|values|filters, to: rows|columns|values|filters, position?: index, replace?}",
|
||||
has_doc,
|
||||
move_field
|
||||
),
|
||||
@@ -333,6 +333,71 @@ fn in_axes(pt: &PivotTable, field: &str) -> bool {
|
||||
pt.rows.iter().chain(pt.columns.iter()).any(|f| f.source_col.eq_ignore_ascii_case(field))
|
||||
}
|
||||
|
||||
/// Name of the Σ Values pseudo-field (where the value fields sit when there are two or more).
|
||||
const VALUES_FIELD: &str = "Σ Values";
|
||||
|
||||
/// Whether `field` names Σ Values: `Σ Values`, or `Values` unless a source field has that name.
|
||||
fn is_values_field(src: &pv::Source, field: &str) -> bool {
|
||||
let f = field.trim();
|
||||
f == VALUES_FIELD || (f.eq_ignore_ascii_case("values") && src.col(f).is_none())
|
||||
}
|
||||
|
||||
/// Keeps Σ Values in place after a field left (`inserted` false) or arrived at index `i` of
|
||||
/// Rows (`rows`) or Columns.
|
||||
fn shift_values(pt: &mut PivotTable, rows: bool, i: usize, inserted: bool) {
|
||||
if pt.values_on_rows != rows {
|
||||
return;
|
||||
}
|
||||
let Some(p) = pt.values_position else { return };
|
||||
// Σ Values stays just ahead of the field it preceded.
|
||||
let p = match inserted {
|
||||
true if i <= p => p + 1,
|
||||
false if i < p => p - 1,
|
||||
_ => p,
|
||||
};
|
||||
let n = if rows { pt.rows.len() } else { pt.columns.len() };
|
||||
pt.values_position = (p < n).then_some(p);
|
||||
}
|
||||
|
||||
/// Takes `field` out of Rows and Columns (Σ Values keeps its place); returns its first entry.
|
||||
fn take_from_axes(pt: &mut PivotTable, field: &str) -> Option<PivotField> {
|
||||
let mut first = None;
|
||||
for rows in [true, false] {
|
||||
loop {
|
||||
let list = if rows { &mut pt.rows } else { &mut pt.columns };
|
||||
let Some(i) = list.iter().position(|f| f.source_col.eq_ignore_ascii_case(field)) else { break };
|
||||
let f = list.remove(i);
|
||||
shift_values(pt, rows, i, false);
|
||||
first.get_or_insert(f);
|
||||
}
|
||||
}
|
||||
first
|
||||
}
|
||||
|
||||
/// Inserts `f` into Rows (`rows`) or Columns at `position` (default: the end).
|
||||
fn insert_axis_field(pt: &mut PivotTable, rows: bool, f: PivotField, position: Option<usize>) {
|
||||
let list = if rows { &mut pt.rows } else { &mut pt.columns };
|
||||
let at = position.unwrap_or(list.len()).min(list.len());
|
||||
list.insert(at, f);
|
||||
shift_values(pt, rows, at, true);
|
||||
}
|
||||
|
||||
/// Moves Σ Values between Rows and Columns, or within Rows, to `position` (default: the end).
|
||||
/// In Columns it always comes after the last column field: the column layout doesn't place it
|
||||
/// anywhere else yet.
|
||||
fn move_values_field(cmd: &str, pt: &mut PivotTable, from: &str, to: &str, position: Option<usize>) -> Result<Json> {
|
||||
let current = if pt.values_on_rows { "rows" } else { "columns" };
|
||||
if from != current {
|
||||
return Err(bad(cmd, format!("`{VALUES_FIELD}` isn't in the {from} area")));
|
||||
}
|
||||
if !matches!(to, "rows" | "columns") {
|
||||
return Err(other("The field you are moving cannot be placed in that PivotTable area."));
|
||||
}
|
||||
pt.values_on_rows = to == "rows";
|
||||
pt.values_position = position.filter(|p| pt.values_on_rows && *p < pt.rows.len());
|
||||
Ok(json!({"field": VALUES_FIELD, "to": to}))
|
||||
}
|
||||
|
||||
/// The canonical source field name for `field`.
|
||||
fn canonical(cmd: &str, src: &pv::Source, field: &str) -> Result<String> {
|
||||
src.col(field).and_then(|c| src.headers.get(c).cloned()).ok_or_else(|| bad(cmd, format!("no field `{field}` in the source data")))
|
||||
@@ -384,21 +449,11 @@ fn add_to_area(
|
||||
if pt.rows.len() + pt.columns.len() >= pv::MAX_AXIS_FIELDS && !in_axes(pt, &field) {
|
||||
return Err(other(format!("A PivotTable can have at most {} row and column fields.", pv::MAX_AXIS_FIELDS)));
|
||||
}
|
||||
let existing = pt
|
||||
.rows
|
||||
.iter()
|
||||
.chain(pt.columns.iter())
|
||||
.find(|f| f.source_col.eq_ignore_ascii_case(&field))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| PivotField { source_col: field.clone(), ..Default::default() });
|
||||
pt.rows.retain(|f| !f.source_col.eq_ignore_ascii_case(&field));
|
||||
pt.columns.retain(|f| !f.source_col.eq_ignore_ascii_case(&field));
|
||||
let existing = take_from_axes(pt, &field).unwrap_or_else(|| PivotField { source_col: field.clone(), ..Default::default() });
|
||||
// A report filter that selects nothing in particular goes away; a selection stays
|
||||
// as the field's hidden items.
|
||||
pt.filters.retain(|f| !(f.source_col.eq_ignore_ascii_case(&field) && f.selected.is_none()));
|
||||
let list = if area == "rows" { &mut pt.rows } else { &mut pt.columns };
|
||||
let at = position.unwrap_or(list.len()).min(list.len());
|
||||
list.insert(at, existing);
|
||||
insert_axis_field(pt, area == "rows", existing, position);
|
||||
}
|
||||
"values" => {
|
||||
if pt.values.len() >= pv::MAX_VALUE_FIELDS {
|
||||
@@ -421,8 +476,7 @@ fn add_to_area(
|
||||
pt.values.insert(at, v);
|
||||
}
|
||||
_ => {
|
||||
pt.rows.retain(|f| !f.source_col.eq_ignore_ascii_case(&field));
|
||||
pt.columns.retain(|f| !f.source_col.eq_ignore_ascii_case(&field));
|
||||
take_from_axes(pt, &field);
|
||||
let existing = pt.filters.iter().position(|f| f.source_col.eq_ignore_ascii_case(&field));
|
||||
let entry = match existing {
|
||||
Some(i) => pt.filters.remove(i),
|
||||
@@ -439,12 +493,14 @@ fn add_to_area(
|
||||
fn remove_from_area(cmd: &str, pt: &mut PivotTable, field: &str, area: &str) -> Result<String> {
|
||||
match area {
|
||||
"rows" | "columns" => {
|
||||
let list = if area == "rows" { &mut pt.rows } else { &mut pt.columns };
|
||||
let rows = area == "rows";
|
||||
let list = if rows { &mut pt.rows } else { &mut pt.columns };
|
||||
let i = list
|
||||
.iter()
|
||||
.position(|f| f.source_col.eq_ignore_ascii_case(field))
|
||||
.ok_or_else(|| bad(cmd, format!("`{field}` isn't in the {area} area")))?;
|
||||
let f = list.remove(i);
|
||||
shift_values(pt, rows, i, false);
|
||||
pt.filters.retain(|x| !x.source_col.eq_ignore_ascii_case(&f.source_col));
|
||||
Ok(f.source_col)
|
||||
}
|
||||
@@ -619,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")
|
||||
@@ -699,6 +761,8 @@ fn pivot_json(wb: &Workbook, si: usize, pt: &PivotTable) -> Json {
|
||||
"style": pt.style,
|
||||
"showHeaders": pt.show_headers,
|
||||
"calculatedFields": pt.calculated_fields.iter().map(|c| json!({"name": c.name, "formula": c.formula})).collect::<Vec<_>>(),
|
||||
"valuesOnRows": pt.values_on_rows,
|
||||
"valuesPosition": pt.values_position,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -890,18 +954,22 @@ fn move_field(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let to = area_param(C, str_param(p, "to"))?;
|
||||
let pos = position_param(p);
|
||||
update(s, p, |pt, src, _| {
|
||||
if is_values_field(src, &field) {
|
||||
return move_values_field(C, pt, from, to, pos);
|
||||
}
|
||||
if from == to {
|
||||
// Reorder within the area.
|
||||
match from {
|
||||
"rows" | "columns" => {
|
||||
let list = if from == "rows" { &mut pt.rows } else { &mut pt.columns };
|
||||
let rows = from == "rows";
|
||||
let list = if rows { &mut pt.rows } else { &mut pt.columns };
|
||||
let i = list
|
||||
.iter()
|
||||
.position(|f| f.source_col.eq_ignore_ascii_case(&field))
|
||||
.ok_or_else(|| bad(C, format!("`{field}` isn't in the {from} area")))?;
|
||||
let f = list.remove(i);
|
||||
let at = pos.unwrap_or(list.len()).min(list.len());
|
||||
list.insert(at, f);
|
||||
shift_values(pt, rows, i, false);
|
||||
insert_axis_field(pt, rows, f, pos);
|
||||
}
|
||||
"values" => {
|
||||
let i = pt
|
||||
@@ -1270,8 +1338,20 @@ fn change_source(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
ok
|
||||
};
|
||||
pt.rows.retain(|f| keep(&f.source_col, &mut dropped));
|
||||
pt.columns.retain(|f| keep(&f.source_col, &mut dropped));
|
||||
// Axis fields leave one by one so Σ Values keeps its place among the rest.
|
||||
for rows in [true, false] {
|
||||
let mut i = 0;
|
||||
loop {
|
||||
let list = if rows { &mut pt.rows } else { &mut pt.columns };
|
||||
let Some(f) = list.get(i) else { break };
|
||||
if keep(&f.source_col, &mut dropped) {
|
||||
i += 1;
|
||||
} else {
|
||||
list.remove(i);
|
||||
shift_values(pt, rows, i, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
pt.values.retain(|f| keep(&f.source_col, &mut dropped));
|
||||
pt.filters.retain(|f| keep(&f.source_col, &mut dropped));
|
||||
Ok(json!({"source": source, "dropped": dropped}))
|
||||
@@ -1722,6 +1802,167 @@ mod tests {
|
||||
assert_eq!(text(&s, "A5"), "Apples");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_on_rows_layouts() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales", "Units"]})).unwrap();
|
||||
assert_eq!(text(&s, "C3"), "Sum of Units");
|
||||
// Σ Values after Region: each region holds one line per value field.
|
||||
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows"})).unwrap();
|
||||
let p = pt(&s);
|
||||
assert!(p.values_on_rows);
|
||||
assert_eq!(p.values_position, None);
|
||||
let r = p.last_range.unwrap();
|
||||
assert_eq!(r.width(), 2);
|
||||
// header + 3 × (region + 2 values) + 2 grand totals
|
||||
assert_eq!(r.height(), 1 + 9 + 2);
|
||||
assert_eq!(text(&s, "A4"), "East");
|
||||
assert_eq!(val(&s, "B4"), Value::Empty);
|
||||
assert_eq!(text(&s, "A5"), "Sum of Sales");
|
||||
assert_eq!(val(&s, "B5"), Value::Number(415.0));
|
||||
assert_eq!(text(&s, "A6"), "Sum of Units");
|
||||
assert_eq!(val(&s, "B6"), Value::Number(33.0));
|
||||
assert_eq!(text(&s, "A13"), "Total Sum of Sales");
|
||||
assert_eq!(val(&s, "B13"), Value::Number(TOTAL));
|
||||
assert_eq!(text(&s, "A14"), "Total Sum of Units");
|
||||
assert_eq!(val(&s, "B14"), Value::Number(72.0));
|
||||
// Tabular, no column field: "Total" heads the data column, as Excel saves it.
|
||||
s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap();
|
||||
assert_eq!(text(&s, "A3"), "Region");
|
||||
assert_eq!(text(&s, "B3"), "Values");
|
||||
assert_eq!(text(&s, "C3"), "Total");
|
||||
s.execute("pivot.layout", json!({"layout": "compact"})).unwrap();
|
||||
// Σ Values before Region: the value fields are the outer items.
|
||||
s.execute("pivot.moveField", json!({"field": "Values", "from": "rows", "to": "rows", "position": 0})).unwrap();
|
||||
assert_eq!(pt(&s).values_position, Some(0));
|
||||
assert_eq!(text(&s, "A4"), "Sum of Sales");
|
||||
assert_eq!(val(&s, "B4"), Value::Empty);
|
||||
assert_eq!(text(&s, "A5"), "East");
|
||||
assert_eq!(val(&s, "B5"), Value::Number(415.0));
|
||||
assert_eq!(text(&s, "A8"), "Sum of Units");
|
||||
assert_eq!(text(&s, "A9"), "East");
|
||||
assert_eq!(val(&s, "B9"), Value::Number(33.0));
|
||||
assert_eq!(text(&s, "A12"), "Total Sum of Sales");
|
||||
assert_eq!(val(&s, "B13"), Value::Number(72.0));
|
||||
// Adding a row field in front keeps Σ Values ahead of Region.
|
||||
s.execute("pivot.addField", json!({"field": "Product", "area": "rows", "position": 0})).unwrap();
|
||||
assert_eq!(pt(&s).values_position, Some(1));
|
||||
s.execute("pivot.removeField", json!({"field": "Product", "area": "rows"})).unwrap();
|
||||
assert_eq!(pt(&s).values_position, Some(0));
|
||||
// No row field: one line per value field and no grand total.
|
||||
s.execute("pivot.removeField", json!({"field": "Region", "area": "rows"})).unwrap();
|
||||
let r = pt(&s).last_range.unwrap();
|
||||
assert_eq!(r.height(), 1 + 2);
|
||||
assert_eq!(text(&s, "A4"), "Sum of Sales");
|
||||
assert_eq!(val(&s, "B4"), Value::Number(TOTAL));
|
||||
assert_eq!(text(&s, "A5"), "Sum of Units");
|
||||
assert_eq!(val(&s, "B5"), Value::Number(72.0));
|
||||
// Back to Columns.
|
||||
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).unwrap();
|
||||
assert!(!pt(&s).values_on_rows);
|
||||
assert_eq!(text(&s, "A3"), "Sum of Sales");
|
||||
assert_eq!(text(&s, "B3"), "Sum of Units");
|
||||
assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "filters"})).is_err());
|
||||
assert!(s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsed_item_puts_values_in_the_values_column() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales", "Units"]})).unwrap();
|
||||
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows"})).unwrap();
|
||||
s.execute("pivot.collapse", json!({"field": "Region", "items": ["East"]})).unwrap();
|
||||
for layout in ["tabular", "outline"] {
|
||||
s.execute("pivot.layout", json!({"layout": layout})).unwrap();
|
||||
assert_eq!(text(&s, "C3"), "Values", "{layout}");
|
||||
let east = (4..20).find(|r| text(&s, &format!("A{r}")) == "East").unwrap();
|
||||
let first = if layout == "tabular" { east } else { east + 1 };
|
||||
assert_eq!(text(&s, &format!("B{first}")), "", "{layout}");
|
||||
assert_eq!(text(&s, &format!("C{first}")), "Sum of Sales", "{layout}");
|
||||
assert_eq!(val(&s, &format!("D{first}")), Value::Number(415.0), "{layout}");
|
||||
assert_eq!(text(&s, &format!("C{}", first + 1)), "Sum of Units", "{layout}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_stay_last_in_columns() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Product"], "columns": ["Region"], "values": ["Sales", "Units"]})).unwrap();
|
||||
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "columns", "position": 0})).unwrap();
|
||||
let p = pt(&s);
|
||||
assert!(!p.values_on_rows);
|
||||
assert_eq!(p.values_position, None);
|
||||
// Σ Values to Rows and back with a position: still last in Columns.
|
||||
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows"})).unwrap();
|
||||
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "rows", "to": "columns", "position": 0})).unwrap();
|
||||
assert_eq!(pt(&s).values_position, None);
|
||||
assert_eq!(text(&s, "B5"), "Sum of Sales");
|
||||
assert_eq!(text(&s, "C5"), "Sum of Units");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_source_keeps_values_ahead_of_its_field() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales", "Units"]})).unwrap();
|
||||
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows", "position": 1})).unwrap();
|
||||
// The new source has no Region: Σ Values stays ahead of Product.
|
||||
s.execute("pivot.changeSource", json!({"source": "Sheet1!B1:E13"})).unwrap();
|
||||
let p = pt(&s);
|
||||
assert_eq!(p.rows.len(), 1);
|
||||
assert_eq!(p.values_position, Some(0));
|
||||
assert_eq!(text(&s, "A4"), "Sum of Sales");
|
||||
assert_eq!(text(&s, "A5"), "Apples");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_on_rows_between_row_fields() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales", "Units"]})).unwrap();
|
||||
s.execute("pivot.moveField", json!({"field": "Σ Values", "from": "columns", "to": "rows", "position": 1})).unwrap();
|
||||
// Compact: East, then per value field its products, then per-value subtotals of East.
|
||||
assert_eq!(text(&s, "A4"), "East");
|
||||
assert_eq!(text(&s, "A5"), "Sum of Sales");
|
||||
assert_eq!(text(&s, "A6"), "Apples");
|
||||
assert_eq!(val(&s, "B6"), Value::Number(160.0));
|
||||
assert_eq!(text(&s, "A9"), "Sum of Units");
|
||||
assert_eq!(text(&s, "A10"), "Apples");
|
||||
assert_eq!(val(&s, "B10"), Value::Number(16.0));
|
||||
assert_eq!(text(&s, "A13"), "East Sum of Sales");
|
||||
assert_eq!(val(&s, "B13"), Value::Number(415.0));
|
||||
assert_eq!(text(&s, "A14"), "East Sum of Units");
|
||||
assert_eq!(val(&s, "B14"), Value::Number(33.0));
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.active().unwrap();
|
||||
assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A5").unwrap())).align.indent, 1);
|
||||
assert_eq!(d.wb.styles.get(sh.style_id(CellRef::parse("A6").unwrap())).align.indent, 2);
|
||||
// Outline: one column per level, "Values" heads the Σ Values column.
|
||||
s.execute("pivot.layout", json!({"layout": "outline"})).unwrap();
|
||||
assert_eq!(pt(&s).last_range.unwrap().width(), 4);
|
||||
assert_eq!(text(&s, "A3"), "Region");
|
||||
assert_eq!(text(&s, "B3"), "Values");
|
||||
assert_eq!(text(&s, "C3"), "Product");
|
||||
assert_eq!(text(&s, "B5"), "Sum of Sales");
|
||||
assert_eq!(text(&s, "C6"), "Apples");
|
||||
assert_eq!(val(&s, "D6"), Value::Number(160.0));
|
||||
// Tabular: labels start on their parent's row; totals per value field below each group.
|
||||
s.execute("pivot.layout", json!({"layout": "tabular"})).unwrap();
|
||||
assert_eq!(text(&s, "A4"), "East");
|
||||
assert_eq!(text(&s, "B4"), "Sum of Sales");
|
||||
assert_eq!(text(&s, "C4"), "Apples");
|
||||
assert_eq!(val(&s, "D4"), Value::Number(160.0));
|
||||
assert_eq!(text(&s, "B7"), "Sum of Units");
|
||||
assert_eq!(text(&s, "C7"), "Apples");
|
||||
assert_eq!(val(&s, "D7"), Value::Number(16.0));
|
||||
assert_eq!(text(&s, "A10"), "East Sum of Sales");
|
||||
assert_eq!(val(&s, "D10"), Value::Number(415.0));
|
||||
assert_eq!(text(&s, "A11"), "East Sum of Units");
|
||||
let r = pt(&s).last_range.unwrap();
|
||||
let last = r.end.row + 1;
|
||||
assert_eq!(text(&s, &format!("A{}", last - 1)), "Total Sum of Sales");
|
||||
assert_eq!(text(&s, &format!("A{last}")), "Total Sum of Units");
|
||||
assert_eq!(val(&s, &format!("D{last}")), Value::Number(72.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_after_source_edits() {
|
||||
let mut s = sales();
|
||||
@@ -2039,4 +2280,38 @@ mod tests {
|
||||
assert!(e.contains("field name is not valid"), "{e}");
|
||||
assert!(s.execute("insert.pivotTable", json!({"source": "NoSuchTable"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_many_value_columns_are_refused() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let mut records: Vec<Json> = vec![json!(["Cat", "Sub", "V"])];
|
||||
for r in 0..64 {
|
||||
for c in 0..64 {
|
||||
records.push(json!([format!("c{r}"), format!("s{c}"), 1.0]));
|
||||
}
|
||||
}
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": records})).unwrap();
|
||||
let req = |n: usize| {
|
||||
let values: Vec<Json> = (0..n).map(|_| json!({"field": "V"})).collect();
|
||||
json!({"source": "A1:C4097", "rows": ["Cat"], "columns": ["Sub"], "values": values})
|
||||
};
|
||||
let e = s.execute("insert.pivotTable", req(256)).unwrap_err().to_string();
|
||||
assert!(e.contains("will not fit"), "{e}");
|
||||
assert!(s.execute("insert.pivotTable", req(128)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsed_column_items_count_as_one_column_group() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let mut records: Vec<Json> = vec![json!(["Grp", "Sub", "V"])];
|
||||
records.extend((0..64).map(|c| json!(["g", format!("s{c}"), 1.0])));
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": records})).unwrap();
|
||||
let values: Vec<Json> = (0..256).map(|_| json!({"field": "V"})).collect();
|
||||
s.execute("insert.pivotTable", json!({"source": "A1:C65", "columns": ["Grp"], "values": values})).unwrap();
|
||||
s.execute("pivot.collapse", json!({"field": "Grp"})).unwrap();
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,7 +391,7 @@ pub struct HfContext {
|
||||
|
||||
/// Today's date and the current time as headers and footers show them (`&D`, `&T`).
|
||||
pub fn hf_date_time(wb: &Workbook) -> (String, String) {
|
||||
let now = gridcraft_calc::now_serial();
|
||||
let now = gridcraft_core::date::serial_in_system(wb.date_system, gridcraft_calc::now_serial());
|
||||
let date = crate::display::format(&Value::Number(now.floor()), "m/d/yyyy", wb).text;
|
||||
let time = crate::display::format(&Value::Number(now), "h:mm AM/PM", wb).text;
|
||||
(date, time)
|
||||
@@ -650,6 +650,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),
|
||||
@@ -1027,6 +1031,21 @@ fn empty_for_overflow(sh: &Sheet, c: CellRef, merges: &[RangeRef]) -> bool {
|
||||
sh.value_ref(c).is_none_or(|v| v.is_empty()) && !merges.iter().any(|m| m.contains(c))
|
||||
}
|
||||
|
||||
/// Unscaled width of the empty Center Across Selection cells right of `c`, which its text is centred over too.
|
||||
fn center_across_width(styles: &StyleTable, sh: &Sheet, c: CellRef, merges: &[RangeRef]) -> f32 {
|
||||
let mut ext = 0.0;
|
||||
let mut col = c.col + 1;
|
||||
while col < MAX_COLS && ext < 20_000.0 {
|
||||
let nc = CellRef::new(c.row, col);
|
||||
if !empty_for_overflow(sh, nc, merges) || styles.get(sh.style_id(nc)).align.h != HAlign::CenterAcross {
|
||||
break;
|
||||
}
|
||||
ext += sh.col_width(col);
|
||||
col += 1;
|
||||
}
|
||||
ext
|
||||
}
|
||||
|
||||
fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f32, f32), merged: bool, merges: &[RangeRef], limits: (f32, f32)) {
|
||||
let (x, y, w, h) = rect;
|
||||
let sh = b.sh;
|
||||
@@ -1061,7 +1080,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'], " ");
|
||||
@@ -1110,8 +1130,11 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3
|
||||
VAlign::Bottom => y + h - pad - size * 0.22 - (n - 1.0) * line_h,
|
||||
};
|
||||
// Overflow into empty neighbours (single-line text, not merged).
|
||||
let (mut clip_l, mut clip_r) = (x, x + w);
|
||||
if !wrap && !merged && !is_num && rot.abs() != 90 && tw + 2.0 * pad + indent > w {
|
||||
// Center Across Selection spans the empty Center Across cells to its right; the text stays within that run.
|
||||
let spans = st.align.h == HAlign::CenterAcross && !wrap && !merged && rot == 0;
|
||||
let across = if spans { center_across_width(&wb.styles, sh, c, merges) * b.k } else { 0.0 };
|
||||
let (mut clip_l, mut clip_r) = (x, x + w + across);
|
||||
if !wrap && !merged && !is_num && rot.abs() != 90 && across <= 0.0 && tw + 2.0 * pad + indent > w {
|
||||
let need = tw + 2.0 * pad + indent - w;
|
||||
let grow = |dir: i64, mut need: f32| -> f32 {
|
||||
let mut ext = 0.0;
|
||||
@@ -1183,7 +1206,7 @@ fn draw_text(pg: &mut Page, b: &Block, c: CellRef, l: &Look, rect: (f32, f32, f3
|
||||
let lw = gridcraft_pdf::text_width(font, size, line);
|
||||
let lx = match h_align {
|
||||
HAlign::Right => x + w - pad - lw - indent,
|
||||
HAlign::Center => x + (w - lw) / 2.0,
|
||||
HAlign::Center => x + (w + across - lw) / 2.0,
|
||||
_ => x + pad + indent,
|
||||
};
|
||||
let ly = first_baseline + i as f32 * line_h;
|
||||
@@ -1765,6 +1788,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_footer_date_follows_the_workbook_date_system() {
|
||||
let mut wb = Workbook::new();
|
||||
let date_1900 = hf_date_time(&wb).0;
|
||||
wb.date_system = gridcraft_core::DateSystem::D1904;
|
||||
assert_eq!(hf_date_time(&wb).0, date_1900);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_footer_section_edits_are_undoable_and_views_are_per_sheet() {
|
||||
let mut s = s();
|
||||
@@ -1906,6 +1937,21 @@ 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 export_writes_file() {
|
||||
let mut s = s();
|
||||
@@ -2046,6 +2092,23 @@ mod tests {
|
||||
assert!(s.execute("file.exportPdf", json!({})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_pdf_without_a_path_returns_bytes_and_never_opens_a_dialog() {
|
||||
let mut s = s();
|
||||
for params in [json!({}), json!({"path": ""}), json!({"path": null})] {
|
||||
let r = s.execute("file.exportPdf", params).unwrap();
|
||||
assert!(r.get("path").is_none(), "no file is written without a path");
|
||||
let bytes = crate::io::base64_decode(r["base64"].as_str().unwrap()).unwrap();
|
||||
assert!(bytes.starts_with(b"%PDF-"));
|
||||
}
|
||||
assert!(s.take_ui_requests().is_empty(), "a programmatic call never queues a dialog");
|
||||
// Headless callers (CLI, MCP) get the same bytes, not an error.
|
||||
assert!(s.execute_headless("file.exportPdf", json!({})).is_ok());
|
||||
// With no workbook open it is a clear error, not a panic.
|
||||
let mut none = Session::new();
|
||||
assert!(none.execute("file.exportPdf", json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bands_unit() {
|
||||
let b = bands(0, 9, |_| 10.0, |_| 35.0, &[], 100);
|
||||
@@ -2063,6 +2126,23 @@ mod tests {
|
||||
assert!(w.len() > 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn center_across_spans_following_empty_center_across_cells() {
|
||||
let mut styles = StyleTable::default();
|
||||
let across = styles.intern(Style { align: Alignment { h: HAlign::CenterAcross, ..Alignment::default() }, ..Style::default() });
|
||||
let mut sh = Sheet::new("S");
|
||||
for col in 0..3 {
|
||||
sh.set_style(CellRef::new(0, col), across);
|
||||
}
|
||||
sh.set_value(CellRef::new(0, 0), Value::Text("Title".into()));
|
||||
let a1 = CellRef::new(0, 0);
|
||||
assert_eq!(center_across_width(&styles, &sh, a1, &[]), sh.col_width(1) + sh.col_width(2));
|
||||
sh.set_value(CellRef::new(0, 2), Value::Text("x".into()));
|
||||
assert_eq!(center_across_width(&styles, &sh, a1, &[]), sh.col_width(1));
|
||||
let merged = [RangeRef::new(CellRef::new(0, 1), CellRef::new(1, 1))];
|
||||
assert_eq!(center_across_width(&styles, &sh, a1, &merged), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_wrapped_text_prints_rotated() {
|
||||
// Upright headers over narrow columns (mark sheets) combine rotation with Wrap Text.
|
||||
|
||||
@@ -249,10 +249,14 @@ fn cancel_copy_mode(s: &mut Session) {
|
||||
|
||||
fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
||||
let (at, count) = lines(s, p, axis)?;
|
||||
let limit = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS };
|
||||
if at.checked_add(count).is_none_or(|end| end > limit) {
|
||||
let cmd = if axis == Axis::Rows { "home.insertRows" } else { "home.insertColumns" };
|
||||
return Err(bad(cmd, "the inserted lines would extend past the end of the worksheet"));
|
||||
}
|
||||
check_lines_protection(s, p, axis, at, count, true)?;
|
||||
let sheet = target_sheet(s, p)?;
|
||||
// Refuse to push data off the sheet.
|
||||
let limit = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS };
|
||||
if let Some(u) = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.used_range()) {
|
||||
let last = if axis == Axis::Rows { u.end.row } else { u.end.col };
|
||||
if last >= at && last as u64 + count as u64 >= limit as u64 {
|
||||
@@ -288,27 +292,65 @@ fn insert_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The areas a line delete/insert targets as `(first line, count)`, merged and sorted from the
|
||||
/// last line to the first so deleting one never shifts the next. A comma-separated list
|
||||
/// (`"3:3,7:8"`) or a multi-area selection yields several; `count` forces the single-area form.
|
||||
fn delete_areas(s: &Session, p: &Json, axis: Axis) -> Result<Vec<(u32, u32)>> {
|
||||
let key = if axis == Axis::Rows { "rows" } else { "cols" };
|
||||
let spans: Vec<(u32, u32)> = if u32_param(p, "count").is_some() {
|
||||
let (at, count) = lines(s, p, axis)?;
|
||||
let limit = if axis == Axis::Rows { MAX_ROWS } else { MAX_COLS };
|
||||
// Deleting past the last line only removes up to the end of the worksheet.
|
||||
let count = count.min(limit.saturating_sub(at)).max(1);
|
||||
vec![(at, at.saturating_add(count - 1))]
|
||||
} 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}")))?;
|
||||
Ok(if axis == Axis::Rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) })
|
||||
})
|
||||
.collect::<Result<_>>()?
|
||||
} else {
|
||||
s.doc()?.selection.ranges.iter().map(|r| if axis == Axis::Rows { (r.start.row, r.end.row) } else { (r.start.col, r.end.col) }).collect()
|
||||
};
|
||||
let mut spans: Vec<(u32, u32)> = spans.into_iter().map(|(a, b)| (a.min(b), a.max(b))).collect();
|
||||
spans.sort_unstable();
|
||||
let mut merged: Vec<(u32, u32)> = Vec::new();
|
||||
for (a, b) in spans {
|
||||
match merged.last_mut() {
|
||||
Some(last) if a <= last.1.saturating_add(1) => last.1 = last.1.max(b),
|
||||
_ => merged.push((a, b)),
|
||||
}
|
||||
}
|
||||
Ok(merged.into_iter().rev().map(|(a, b)| (a, b - a + 1)).collect())
|
||||
}
|
||||
|
||||
fn delete_lines(s: &mut Session, p: &Json, axis: Axis) -> Result<Json> {
|
||||
let (at, count) = lines(s, p, axis)?;
|
||||
check_lines_protection(s, p, axis, at, count, false)?;
|
||||
let areas = delete_areas(s, p, axis)?;
|
||||
for &(at, count) in &areas {
|
||||
check_lines_protection(s, p, axis, at, count, false)?;
|
||||
}
|
||||
let sheet = target_sheet(s, p)?;
|
||||
let out = edit(s, |cx| {
|
||||
let name = cx.wb.sheet(sheet).map(|s| s.name.clone()).unwrap_or_default();
|
||||
cx.protection_checked = true;
|
||||
{
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
let block = match axis {
|
||||
Axis::Rows => RangeRef::rows(at, (at + count - 1).min(MAX_ROWS - 1)),
|
||||
Axis::Cols => RangeRef::cols(at, (at + count - 1).min(MAX_COLS - 1)),
|
||||
};
|
||||
sh.take_cells(block);
|
||||
match axis {
|
||||
Axis::Rows => sh.cells.shift_rows(at + count, -(count as i64)),
|
||||
Axis::Cols => sh.cells.shift_cols(at + count, -(count as i64)),
|
||||
// Last area first, so the earlier ones keep their positions.
|
||||
for (at, count) in areas {
|
||||
{
|
||||
let sh = cx.sheet_mut(sheet)?;
|
||||
let block = match axis {
|
||||
Axis::Rows => RangeRef::rows(at, (at + count - 1).min(MAX_ROWS - 1)),
|
||||
Axis::Cols => RangeRef::cols(at, (at + count - 1).min(MAX_COLS - 1)),
|
||||
};
|
||||
sh.take_cells(block);
|
||||
match axis {
|
||||
Axis::Rows => sh.cells.shift_rows(at + count, -(count as i64)),
|
||||
Axis::Cols => sh.cells.shift_cols(at + count, -(count as i64)),
|
||||
}
|
||||
shift_sheet_features(sh, axis, at, count, false);
|
||||
}
|
||||
shift_sheet_features(sh, axis, at, count, false);
|
||||
rewrite(&mut cx.wb, &name, &Edit::Delete { axis, at, count });
|
||||
}
|
||||
rewrite(&mut cx.wb, &name, &Edit::Delete { axis, at, count });
|
||||
cx.structural = true;
|
||||
Ok(Json::Null)
|
||||
})?;
|
||||
@@ -982,7 +1024,14 @@ fn shift_table_columns(t: &mut gridcraft_model::Table, at: u32, count: u32, inse
|
||||
break name;
|
||||
}
|
||||
};
|
||||
let col = gridcraft_model::TableColumn { name, totals: Default::default(), totals_label: None, formula: None };
|
||||
let col = gridcraft_model::TableColumn {
|
||||
name,
|
||||
totals: Default::default(),
|
||||
totals_label: None,
|
||||
formula: None,
|
||||
unique_name: None,
|
||||
xml: None,
|
||||
};
|
||||
t.columns.insert((idx + k).min(t.columns.len()), col);
|
||||
}
|
||||
}
|
||||
|
||||
+1043
-67
File diff suppressed because it is too large
Load Diff
@@ -64,7 +64,14 @@ fn known(s: &Session, w: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Longest word we generate suggestions for. Candidate text grows with the square of the word length, so longer
|
||||
/// tokens (no real word is this long) get no suggestions instead of an unbounded allocation.
|
||||
const MAX_SUGGEST_LEN: usize = 64;
|
||||
|
||||
fn edits1(w: &str) -> Vec<String> {
|
||||
if w.chars().count() > MAX_SUGGEST_LEN {
|
||||
return Vec::new();
|
||||
}
|
||||
let chars: Vec<char> = w.chars().collect();
|
||||
let mut out = Vec::new();
|
||||
let letters = "abcdefghijklmnopqrstuvwxyz";
|
||||
@@ -177,3 +184,16 @@ fn thesaurus(_: &mut Session, p: &Json) -> Result<Json> {
|
||||
related.truncate(20);
|
||||
Ok(json!(related))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn edits1_is_capped_for_very_long_words() {
|
||||
assert_eq!(edits1("helo").len(), 54 * 4 + 25);
|
||||
assert_eq!(edits1(&"a".repeat(MAX_SUGGEST_LEN)).len(), 54 * MAX_SUGGEST_LEN + 25);
|
||||
assert!(edits1(&"a".repeat(MAX_SUGGEST_LEN + 1)).is_empty());
|
||||
assert!(edits1(&"a".repeat(1_000_000)).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,11 +10,13 @@ use gridcraft_numfmt::{Formatted, NumberFormat, format_value};
|
||||
/// Parsed number formats are cached by code.
|
||||
fn parsed(code: &str) -> NumberFormat {
|
||||
static CACHE: std::sync::LazyLock<Mutex<HashMap<String, NumberFormat>>> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
let mut m = CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(f) = m.get(code) {
|
||||
let lock = || CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(f) = lock().get(code) {
|
||||
return f.clone();
|
||||
}
|
||||
// Parse outside the lock, so a slow code never blocks the other threads' lookups.
|
||||
let f = NumberFormat::parse(code);
|
||||
let mut m = lock();
|
||||
if m.len() > 4096 {
|
||||
m.clear();
|
||||
}
|
||||
@@ -93,7 +95,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;
|
||||
|
||||
+87
-14
@@ -6,6 +6,7 @@ use std::sync::Arc;
|
||||
use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial, days_in_month, serial_from_ymd};
|
||||
use gridcraft_core::{CellRef, RangeRef, Value};
|
||||
use gridcraft_model::{Cell, Formula};
|
||||
use gridcraft_numfmt::{FormatKind, NumberFormat};
|
||||
|
||||
use crate::Result;
|
||||
use crate::cmd::Ctx;
|
||||
@@ -65,21 +66,25 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
|
||||
if forward { (src.end.col + 1..=target.end.col).collect() } else { (target.start.col..src.start.col).rev().collect() };
|
||||
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 detected = Pattern::detect(
|
||||
&src_cells,
|
||||
&custom,
|
||||
cx.wb.date_system,
|
||||
cx.wb.styles.get(src_cells.first().and_then(|c| c.1.as_ref()).map(|c| c.style).unwrap_or_default()).num_fmt.as_str(),
|
||||
);
|
||||
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 }
|
||||
} 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 {
|
||||
src_cells.iter().map(|c| Pattern::detect(std::slice::from_ref(c), &custom, cx.wb.date_system, fmt(c))).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
for (k, t) in targets.iter().enumerate() {
|
||||
let step = k as i64 + 1;
|
||||
let idx = k % src_cells.len().max(1);
|
||||
let (sc, scell) = src_cells.get(if forward { idx } else { src_cells.len() - 1 - idx }).cloned().unwrap_or((src.start, None));
|
||||
let src_i = if forward { idx } else { src_cells.len() - 1 - idx };
|
||||
let (sc, scell) = src_cells.get(src_i).cloned().unwrap_or((src.start, None));
|
||||
let mut cell = scell.clone().unwrap_or_default();
|
||||
match mode {
|
||||
FillMode::Formats => {
|
||||
@@ -96,7 +101,10 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
|
||||
cell.formula = Some(Arc::new(Formula::from_expr(shifted)));
|
||||
cell.value = Value::Empty;
|
||||
}
|
||||
} else if let Some(v) = pattern.value(step, forward, src_cells.len()) {
|
||||
} else if let Some(v) = match per_cell.get(src_i) {
|
||||
Some(p) => p.value(k as i64 / src_cells.len() as i64 + 1, forward, 1),
|
||||
None => pattern.value(step, forward, src_cells.len()),
|
||||
} {
|
||||
cell.value = v;
|
||||
}
|
||||
cell.style = style;
|
||||
@@ -173,8 +181,14 @@ impl Pattern {
|
||||
let last = nums.last().copied().unwrap_or(0.0);
|
||||
let is_date = fmt.contains('d') || fmt.contains('y') || (fmt.contains('m') && !fmt.contains('h'));
|
||||
if nums.len() == 1 {
|
||||
// A single number copies; a single date increments by a day.
|
||||
return if is_date { Pattern::Linear { last, first, step: 1.0 } } else { Pattern::Copy };
|
||||
// A single number copies; a single date increments by a day, a single time by an hour.
|
||||
return if is_date {
|
||||
Pattern::Linear { last, first, step: 1.0 }
|
||||
} else if NumberFormat::parse(fmt).kind() == FormatKind::Time {
|
||||
Pattern::Linear { last, first, step: 1.0 / 24.0 }
|
||||
} else {
|
||||
Pattern::Copy
|
||||
};
|
||||
}
|
||||
// Dates that differ by whole months.
|
||||
if is_date && nums.len() >= 2 {
|
||||
@@ -260,8 +274,12 @@ impl Pattern {
|
||||
Pattern::TextNum { prefix, suffix, last, first, step, width } => {
|
||||
let base = if forward { *last } else { *first };
|
||||
let n = base + step * k * sign;
|
||||
let n = n.abs();
|
||||
Some(Value::text(format!("{prefix}{n:0width$}{suffix}", width = *width)))
|
||||
// `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() };
|
||||
Some(Value::text(format!("{prefix}{n:0width$}{}", ordinal_suffix(n, suffix), width = *width)))
|
||||
}
|
||||
Pattern::List { list, last, first, step, upper, lower } => {
|
||||
let len = list.len() as i64;
|
||||
@@ -309,6 +327,23 @@ fn round15(x: f64) -> f64 {
|
||||
if !p.is_finite() || p == 0.0 { x } else { (x * p).round() / p }
|
||||
}
|
||||
|
||||
/// `1st Period` → `2nd Period`: an English ordinal ending is rewritten to suit the new number.
|
||||
fn ordinal_suffix(n: i64, suffix: &str) -> String {
|
||||
let (Some(end), Some(rest)) = (suffix.get(..2), suffix.get(2..)) else { return suffix.to_string() };
|
||||
if !["st", "nd", "rd", "th"].contains(&end.to_ascii_lowercase().as_str()) || rest.starts_with(char::is_alphanumeric) {
|
||||
return suffix.to_string();
|
||||
}
|
||||
let new = match (n % 10, n % 100) {
|
||||
(_, 11..=13) => "th",
|
||||
(1, _) => "st",
|
||||
(2, _) => "nd",
|
||||
(3, _) => "rd",
|
||||
_ => "th",
|
||||
};
|
||||
let new = if end.chars().all(|c| c.is_ascii_uppercase()) { new.to_ascii_uppercase() } else { new.to_string() };
|
||||
format!("{new}{rest}")
|
||||
}
|
||||
|
||||
/// `Item 007` → ("Item ", 7, "", 3); `3rd` style leading numbers → ("", 3, "rd", 1).
|
||||
fn split_num(t: &str) -> Option<(String, i64, String, usize)> {
|
||||
let chars: Vec<char> = t.chars().collect();
|
||||
@@ -338,6 +373,7 @@ pub fn series(cx: &mut Ctx, sheet: usize, r: RangeRef, rows: bool, kind: &str, s
|
||||
let style = sh.style_id(first_cell);
|
||||
let mut v = start;
|
||||
for i in 1..len {
|
||||
let prev = v;
|
||||
v = match kind {
|
||||
"growth" => v * step,
|
||||
"date" => {
|
||||
@@ -350,7 +386,10 @@ pub fn series(cx: &mut Ctx, sheet: usize, r: RangeRef, rows: bool, kind: &str, s
|
||||
let mut left = step.abs() as i64;
|
||||
while left > 0 {
|
||||
n += step.signum();
|
||||
if datetime_from_serial(sys, n).is_some_and(|d| d.weekday != 0 && d.weekday != 6) {
|
||||
let Some(day) = datetime_from_serial(sys, n) else {
|
||||
return Err(crate::EngineError::Other("The weekday series runs past the supported date range.".into()));
|
||||
};
|
||||
if day.weekday != 0 && day.weekday != 6 {
|
||||
left -= 1;
|
||||
}
|
||||
}
|
||||
@@ -361,8 +400,9 @@ pub fn series(cx: &mut Ctx, sheet: usize, r: RangeRef, rows: bool, kind: &str, s
|
||||
}
|
||||
_ => v + step,
|
||||
};
|
||||
// The stop value bounds the series in the direction it runs (a growth by 0.5 falls).
|
||||
if let Some(s) = stop
|
||||
&& ((step >= 0.0 && v > s) || (step < 0.0 && v < s))
|
||||
&& ((v >= prev && v > s) || (v < prev && v < s))
|
||||
{
|
||||
break;
|
||||
}
|
||||
@@ -579,4 +619,37 @@ mod tests {
|
||||
assert_eq!(split_num("Q003x"), Some(("Q".into(), 3, "x".into(), 3)));
|
||||
assert_eq!(split_num("abc"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn times_quarters_ordinals_and_mixed_sources() {
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
let mut fill = |values: serde_json::Value, source: &str, target: &str| {
|
||||
s.execute("range.setValues", serde_json::json!({"range": source.get(..2).unwrap(), "values": values})).unwrap();
|
||||
s.execute("edit.autoFill", serde_json::json!({"source": source, "target": target})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
let r = RangeRef::parse(target).unwrap();
|
||||
r.iter().map(|c| crate::display::cell_text(&s.doc().unwrap().wb, sh, c)).collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(fill(serde_json::json!([["9:00"]]), "A1", "A1:A3"), ["9:00", "10:00", "11:00"]);
|
||||
assert_eq!(fill(serde_json::json!([["Qtr3"]]), "B1", "B1:B3"), ["Qtr3", "Qtr4", "Qtr1"]);
|
||||
assert_eq!(fill(serde_json::json!([["Quarter 4"]]), "C1", "C1:C2"), ["Quarter 4", "Quarter 1"]);
|
||||
assert_eq!(fill(serde_json::json!([["1st Period"]]), "D1", "D1:D4"), ["1st Period", "2nd Period", "3rd Period", "4th Period"]);
|
||||
assert_eq!(
|
||||
fill(serde_json::json!([["Product 1"], ["On backorder"]]), "E1:E2", "E1:E6"),
|
||||
["Product 1", "On backorder", "Product 2", "On backorder", "Product 3", "On backorder"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falling_growth_series_stops_at_its_stop_value() {
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", serde_json::json!({"cell": "A1", "input": "5"})).unwrap();
|
||||
s.execute("edit.fillSeries", serde_json::json!({"range": "A1:A6", "type": "growth", "step": 0.5, "stop": 1})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.active().unwrap();
|
||||
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", "", "", ""]);
|
||||
}
|
||||
}
|
||||
|
||||
+318
-31
@@ -55,8 +55,17 @@ 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 {
|
||||
return open_plain(name, bytes);
|
||||
let (mut wb, warnings) = open_plain(name, bytes, csv_as_text)?;
|
||||
fit_imported_rows(&mut wb);
|
||||
return Ok((wb, warnings));
|
||||
}
|
||||
// A scheme no password opens is said straight away, rather than after asking for one.
|
||||
let decrypt_error = |e: gridcraft_xlsx::DecryptError| EngineError::Other(decrypt_message(name, e));
|
||||
@@ -68,11 +77,36 @@ 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 (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))
|
||||
}
|
||||
|
||||
/// Excel auto-fits rows to their tallest font when opening a file: rows without a user-set
|
||||
/// height grow to fit their content. Rows the file made taller keep their height — our
|
||||
/// line-height estimate can differ from Excel's, and shrinking files that already look
|
||||
/// right would be a visible regression. Custom-height rows are left alone entirely.
|
||||
fn fit_imported_rows(wb: &mut Workbook) {
|
||||
for si in 0..wb.sheets.len() {
|
||||
let Some(sh) = wb.sheet(si) else { continue };
|
||||
let mut rows: Vec<u32> = sh.cells.iter().map(|(c, _)| c.row).collect();
|
||||
rows.sort_unstable();
|
||||
rows.dedup();
|
||||
for row in rows {
|
||||
let before = wb.sheet(si).and_then(|sh| sh.rows.get(&row)).and_then(|i| i.size);
|
||||
crate::cmd::auto_row_height(wb, si, row);
|
||||
let Some(before) = before else { continue };
|
||||
let after = wb.sheet(si).and_then(|sh| sh.rows.get(&row)).and_then(|i| i.size);
|
||||
if after.is_none_or(|a| before > a + 0.5)
|
||||
&& let Some(sh) = wb.sheet_mut(si)
|
||||
{
|
||||
sh.rows.entry(row).or_default().size = Some(before);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What to tell the user when an encrypted workbook can't be decrypted. Never quotes the password.
|
||||
fn decrypt_message(name: &str, e: gridcraft_xlsx::DecryptError) -> String {
|
||||
match e {
|
||||
@@ -85,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 {
|
||||
@@ -102,6 +136,8 @@ fn open_plain(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
|
||||
let import = match sniffed {
|
||||
gridcraft_xlsx::Format::Ods => Some(FileKind::Ods),
|
||||
gridcraft_xlsx::Format::Xlsb => Some(FileKind::Xlsb),
|
||||
// An XLSX package is never read as ODS/XLSB just because of its name.
|
||||
gridcraft_xlsx::Format::Xlsx => None,
|
||||
_ => kind.filter(|k| matches!(k, FileKind::Ods | FileKind::Xlsb)),
|
||||
};
|
||||
if let Some(import) = import {
|
||||
@@ -109,7 +145,8 @@ fn open_plain(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
|
||||
let (wb, report) = read(bytes).map_err(|e| EngineError::Other(format!("We can't import '{name}': {e}")))?;
|
||||
return Ok((wb, report.warnings));
|
||||
}
|
||||
if sniffed == gridcraft_xlsx::Format::Xlsx || kind == Some(FileKind::Xlsx) {
|
||||
// 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}")))?;
|
||||
return Ok((wb, report.warnings));
|
||||
}
|
||||
@@ -123,7 +160,7 @@ fn open_plain(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
|
||||
Ok((wb, vec![]))
|
||||
}
|
||||
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;
|
||||
@@ -131,7 +168,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![]))
|
||||
@@ -144,7 +181,9 @@ 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");
|
||||
let clean: String = stem.chars().filter(|c| !matches!(c, ':' | '\\' | '/' | '?' | '*' | '[' | ']')).take(31).collect();
|
||||
let clean = clean.trim_matches('\'').to_string();
|
||||
// "History" is reserved (see `Workbook::check_sheet_name`): keep the default name instead.
|
||||
if !clean.is_empty()
|
||||
&& !clean.eq_ignore_ascii_case("History")
|
||||
&& let Some(sh) = wb.sheet_mut(0)
|
||||
{
|
||||
sh.name = clean;
|
||||
@@ -153,8 +192,19 @@ fn rename_first_sheet(wb: &mut Workbook, path: &str) {
|
||||
|
||||
/// Encodes a workbook in the format chosen by `path`'s extension.
|
||||
pub fn save_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
|
||||
// An unknown or missing extension is refused rather than written as an XLSX under that name:
|
||||
// a typo like `.cvs` would otherwise produce a file no other app recognises.
|
||||
let Some(kind) = FileKind::from_path(path) else {
|
||||
let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or(path);
|
||||
return Err(EngineError::Other(format!("{name}: unknown file format. Save as .xlsx, .csv, .tsv, .json or .html.")));
|
||||
};
|
||||
save_bytes_as(wb, path, kind)
|
||||
}
|
||||
|
||||
/// Encodes a workbook as `kind`, whatever `path`'s extension says.
|
||||
pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8>> {
|
||||
let sheet = wb.active_sheet;
|
||||
match FileKind::from_path(path).unwrap_or(FileKind::Xlsx) {
|
||||
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())),
|
||||
@@ -164,17 +214,31 @@ pub fn save_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
|
||||
FileKind::Xlsb => Err(EngineError::Other("XLSB is supported for data import only. Save as .xlsx or another supported export format.".into())),
|
||||
FileKind::Ods => Err(EngineError::Other("ODS is supported for data import only. Save as .xlsx or another supported export format.".into())),
|
||||
// A workbook read from CSV keeps the separator it was read with (a `;` file stays `;`).
|
||||
FileKind::Csv => Ok(wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).unwrap_or_default()),
|
||||
FileKind::Tsv => Ok(wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).unwrap_or_default()),
|
||||
FileKind::Csv => wb.sheet(sheet).map(|sh| csv(wb, sh, wb.csv_delimiter.unwrap_or(b','))).transpose().map(Option::unwrap_or_default),
|
||||
FileKind::Tsv => wb.sheet(sheet).map(|sh| csv(wb, sh, b'\t')).transpose().map(Option::unwrap_or_default),
|
||||
FileKind::Json => serde_json::to_vec_pretty(wb).map_err(|e| EngineError::Other(e.to_string())),
|
||||
FileKind::Html => Ok(to_html(wb, sheet).into_bytes()),
|
||||
FileKind::Html => to_html(wb, sheet).map(String::into_bytes),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`save_bytes`] for the file at `path`: relative paths to linked workbooks are rewritten for
|
||||
/// its folder, so they still name the same files.
|
||||
pub fn save_file_bytes(wb: &Workbook, path: &str) -> Result<Vec<u8>> {
|
||||
match source_dir(path).and_then(|f| gridcraft_xlsx::rebase_links(wb, &f)) {
|
||||
Some(rebased) => save_bytes(&rebased, path),
|
||||
None => save_bytes(wb, path),
|
||||
}
|
||||
}
|
||||
|
||||
/// The folder of the file at `path`, which relative paths to linked workbooks are relative to.
|
||||
pub fn source_dir(path: &str) -> Option<std::path::PathBuf> {
|
||||
std::path::absolute(path).ok()?.parent().map(std::path::Path::to_path_buf)
|
||||
}
|
||||
|
||||
/// 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 `#####`.
|
||||
fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Vec<u8> {
|
||||
fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Result<Vec<u8>> {
|
||||
gridcraft_xlsx::write_csv(sh, delimiter, |c| {
|
||||
// General's grid-width formatting rounds large identifiers to scientific notation.
|
||||
// Delimited files have no column width, so preserve the whole numeric value instead.
|
||||
@@ -187,13 +251,14 @@ fn csv(wb: &Workbook, sh: &Sheet, delimiter: u8) -> Vec<u8> {
|
||||
crate::display::value_text(wb, sh, c)
|
||||
}
|
||||
})
|
||||
.map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -212,16 +277,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;
|
||||
|
||||
@@ -234,7 +342,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;
|
||||
@@ -252,11 +360,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 {
|
||||
@@ -296,17 +405,27 @@ 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).
|
||||
pub fn to_html(wb: &Workbook, sheet: usize) -> String {
|
||||
let Some(sh) = wb.sheet(sheet) else { return String::new() };
|
||||
pub fn to_html(wb: &Workbook, sheet: usize) -> Result<String> {
|
||||
let Some(sh) = wb.sheet(sheet) else { return Ok(String::new()) };
|
||||
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:2px 4px;white-space:nowrap}}</style></head><body><table{}>",
|
||||
esc(&sh.name),
|
||||
table_dir(sh)
|
||||
);
|
||||
if let Some(r) = sh.used_range() {
|
||||
for row in r.start.row..=r.end.row.min(r.start.row + 100_000) {
|
||||
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()
|
||||
)));
|
||||
}
|
||||
for row in r.start.row..=r.end.row {
|
||||
if sh.is_row_hidden(row) {
|
||||
continue;
|
||||
}
|
||||
@@ -316,20 +435,22 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> String {
|
||||
if sh.merges.iter().any(|m| m.contains(c) && m.start != c) {
|
||||
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()))
|
||||
.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
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -350,8 +471,12 @@ pub fn read_file(path: &str) -> Result<Vec<u8>> {
|
||||
pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
// 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).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
std::fs::rename(&tmp, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path)).map_err(|e| {
|
||||
// A write or rename that fails (full disk, the workbook locked by another app) leaves
|
||||
// no temporary file beside the user's workbook.
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
EngineError::Other(format!("{path}: {e}"))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -429,6 +554,122 @@ 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_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();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
sh.set_value(CellRef::new(0, 0), gridcraft_core::Value::text("near"));
|
||||
sh.set_value(CellRef::new(149_999, 0), gridcraft_core::Value::text("far"));
|
||||
assert!(save_bytes(&wb, "book.html").is_err());
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
sh.remove_cell(CellRef::new(149_999, 0));
|
||||
sh.set_value(CellRef::new(100_000, 0), gridcraft_core::Value::text("far"));
|
||||
let html = String::from_utf8(save_bytes(&wb, "book.html").unwrap()).unwrap();
|
||||
assert!(html.contains(">near</td>") && html.contains(">far</td>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macros_are_saved_only_into_xlsm() {
|
||||
let mut wb = Workbook::new();
|
||||
@@ -445,6 +686,37 @@ mod tests {
|
||||
assert!(wb.vba_project.is_some(), "the open workbook keeps its macros");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_named_history_does_not_get_the_reserved_sheet_name() {
|
||||
for file in ["History.csv", "hIsToRy.csv", "history.tsv"] {
|
||||
let (wb, _) = open_bytes(file, b"1\n").unwrap();
|
||||
let name = wb.sheets[0].name.clone();
|
||||
assert!(wb.check_sheet_name(&name, Some(0)).is_ok(), "{file} opened as {name:?}");
|
||||
}
|
||||
let (wb, _) = open_bytes("Sales.csv", b"1\n").unwrap();
|
||||
assert_eq!(wb.sheets[0].name, "Sales");
|
||||
}
|
||||
|
||||
/// A save that can't be put in place (here a folder already has the workbook's name, as
|
||||
/// with a file another app holds locked) fails without leaving `<name>.sctmp` behind.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn failed_save_leaves_no_temporary_file() {
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-write-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let blocked = dir.join("Book1.xlsx");
|
||||
std::fs::create_dir(&blocked).unwrap();
|
||||
assert!(write_file(blocked.to_str().unwrap(), b"workbook").is_err());
|
||||
assert!(!dir.join("Book1.xlsx.sctmp").exists(), "the temporary file is removed");
|
||||
// An ordinary save still works and leaves only the workbook.
|
||||
let ok = dir.join("Book2.xlsx");
|
||||
write_file(ok.to_str().unwrap(), b"workbook").unwrap();
|
||||
assert_eq!(std::fs::read(&ok).unwrap(), b"workbook");
|
||||
assert!(!dir.join("Book2.xlsx.sctmp").exists());
|
||||
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> {
|
||||
@@ -608,4 +880,19 @@ mod tests {
|
||||
"{types}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_wins_over_a_mismatched_extension() {
|
||||
let mut wb = Workbook::new();
|
||||
wb.sheet_mut(0).unwrap().set_value(CellRef::new(0, 0), gridcraft_core::Value::Number(7.0));
|
||||
let package = save_bytes(&wb, "book.xlsx").unwrap();
|
||||
for name in ["book.xlsx", "book.ods", "book.xlsb", "book.csv"] {
|
||||
let (opened, _) = open_bytes(name, &package).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(opened.sheet(0).unwrap().value(CellRef::new(0, 0)), gridcraft_core::Value::Number(7.0), "{name}");
|
||||
}
|
||||
for name in ["data.xlsx", "data.csv"] {
|
||||
let (opened, _) = open_bytes(name, b"a,b\n1,2\n").unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
assert_eq!(opened.sheet(0).unwrap().value(CellRef::new(0, 1)), gridcraft_core::Value::text("b"), "{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ pub mod cmd;
|
||||
pub mod display;
|
||||
pub mod fill;
|
||||
pub mod io;
|
||||
pub mod links;
|
||||
pub mod outline;
|
||||
pub mod paper;
|
||||
pub mod pivot;
|
||||
@@ -108,6 +109,9 @@ pub struct DocState {
|
||||
pub sheet_selections: Vec<Selection>,
|
||||
/// Allow Edit Ranges unlocked with their password in this session: (sheet name, title).
|
||||
pub unlocked_ranges: Vec<(String, String)>,
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
@@ -132,6 +136,7 @@ impl DocState {
|
||||
title,
|
||||
sheet_selections: vec![],
|
||||
unlocked_ranges: vec![],
|
||||
content_kind: None,
|
||||
}
|
||||
}
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
//! Which hyperlink targets may be opened. A cell's link comes from the document (often a file
|
||||
//! somebody sent), so it is hostile input: only `http`, `https` and `mailto` ever leave the
|
||||
//! workbook for the system; `file:`, `smb:`, UNC paths, custom URL handlers, `javascript:` and
|
||||
//! the like are refused. Everything without a scheme stays an in-workbook reference.
|
||||
|
||||
use gridcraft_core::RangeRef;
|
||||
|
||||
/// What a link target is.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum LinkKind {
|
||||
/// A cell, range, name or table of the workbook (`Sheet2!A1`, `Total`).
|
||||
Internal(String),
|
||||
/// An address to hand to the system, trimmed: `http`, `https` or `mailto` only.
|
||||
External(String),
|
||||
/// Refused, with the reason to show.
|
||||
Refused(String),
|
||||
}
|
||||
|
||||
const ALLOWED: [&str; 3] = ["http", "https", "mailto"];
|
||||
|
||||
/// Classifies a link target. Never panics; the single place that decides what may be opened.
|
||||
pub fn classify(target: &str) -> LinkKind {
|
||||
let t = target.trim();
|
||||
if t.chars().any(char::is_control) {
|
||||
return LinkKind::Refused("The link contains control characters.".into());
|
||||
}
|
||||
if t.starts_with("\\\\") || t.starts_with("//") {
|
||||
return LinkKind::Refused("Links to network locations aren't opened.".into());
|
||||
}
|
||||
if RangeRef::parse(t).is_some() {
|
||||
return LinkKind::Internal(t.to_string());
|
||||
}
|
||||
let Some((scheme, rest)) = t.split_once(':') else { return LinkKind::Internal(t.to_string()) };
|
||||
let scheme_like = scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
|
||||
&& scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'));
|
||||
if !scheme_like {
|
||||
return LinkKind::Internal(t.to_string());
|
||||
}
|
||||
let lower = scheme.to_ascii_lowercase();
|
||||
if ALLOWED.contains(&lower.as_str()) && !rest.trim().is_empty() {
|
||||
LinkKind::External(t.to_string())
|
||||
} else {
|
||||
LinkKind::Refused(format!("Links of type \"{lower}:\" aren't opened. Only http, https and mailto links are allowed."))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_http_https_and_mailto_leave_the_workbook() {
|
||||
for ok in ["http://a.example/x", "HTTPS://A.example", " https://a.example ", "MailTo:a@b.example"] {
|
||||
assert!(matches!(classify(ok), LinkKind::External(_)), "{ok}");
|
||||
}
|
||||
assert_eq!(classify(" https://a.example "), LinkKind::External("https://a.example".into()));
|
||||
for bad in [
|
||||
"javascript:alert(1)",
|
||||
"JaVaScRiPt:alert(1)",
|
||||
"file:///etc/passwd",
|
||||
"smb://host/share",
|
||||
"ms-excel:ofe|u|http://x",
|
||||
"\\\\host\\share\\x.exe",
|
||||
"//host/share",
|
||||
"C:\\Windows\\calc.exe",
|
||||
"\tjavascript:1",
|
||||
"java\nscript:1",
|
||||
"\u{1}http://a.example",
|
||||
"http:",
|
||||
"mailto:",
|
||||
"x-custom://a",
|
||||
] {
|
||||
assert!(matches!(classify(bad), LinkKind::Refused(_)), "{bad:?} -> {:?}", classify(bad));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn references_stay_internal() {
|
||||
for r in ["Sheet2!A1", "'My Sheet'!B2:C3", "A1:B2", "#Sheet1!A1", "Total", "", " "] {
|
||||
assert!(matches!(classify(r), LinkKind::Internal(_)), "{r:?} -> {:?}", classify(r));
|
||||
}
|
||||
}
|
||||
}
|
||||
+219
-34
@@ -560,6 +560,8 @@ struct Line {
|
||||
values: bool,
|
||||
/// (column, value, indent, number format)
|
||||
labels: Vec<(usize, Value, u8, Option<String>)>,
|
||||
/// The value field of the line when Σ Values sits in Rows (`None`: one per data column).
|
||||
vi: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
@@ -579,6 +581,10 @@ struct Ctx<'a> {
|
||||
rows: Vec<&'a PivotField>,
|
||||
row_items: Vec<FieldItems>,
|
||||
row_fmts: Vec<String>,
|
||||
/// Depth of Σ Values among the row fields when it sits in Rows (two or more value fields).
|
||||
vpos: Option<usize>,
|
||||
/// Value field captions.
|
||||
val_names: Vec<String>,
|
||||
}
|
||||
|
||||
impl Ctx<'_> {
|
||||
@@ -588,26 +594,105 @@ impl Ctx<'_> {
|
||||
(it.value.clone(), fmt, it.label.clone())
|
||||
}
|
||||
|
||||
fn flatten(&self, nodes: &[Node], depth: usize, prefix: &mut Vec<u32>, out: &mut Vec<Line>) {
|
||||
/// (column, indent) of a row level (Σ Values counts as a level) in Compact/Outline form.
|
||||
fn level_cell(&self, level: usize) -> (usize, u8) {
|
||||
if self.pt.layout == PivotLayout::Outline { (level, 0) } else { (0, level.min(250) as u8) }
|
||||
}
|
||||
|
||||
/// Σ Values comes right here, at row field depth `depth` (`vi`: the value field already chosen above).
|
||||
fn values_here(&self, depth: usize, vi: Option<usize>) -> bool {
|
||||
vi.is_none() && self.vpos == Some(depth)
|
||||
}
|
||||
|
||||
/// Per-value subtotal lines of an item with Σ Values below it ("East Sum of Sales").
|
||||
fn value_subtotals(&self, col: usize, indent: u8, label: &str, prefix: &[u32], out: &mut Vec<Line>) {
|
||||
for (vi, name) in self.val_names.iter().enumerate() {
|
||||
out.push(Line {
|
||||
kind: LineKind::Subtotal,
|
||||
leaf: false,
|
||||
path: prefix.to_vec(),
|
||||
values: true,
|
||||
labels: vec![(col, Value::text(format!("{label} {name}")), indent, None)],
|
||||
vi: Some(vi),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The Σ Values level at row field depth `depth`: one item per value field, each followed by
|
||||
/// the row fields below it (`nodes`) or, innermost, holding that value.
|
||||
fn flatten_values(&self, nodes: &[Node], depth: usize, prefix: &mut Vec<u32>, out: &mut Vec<Line>) {
|
||||
let (col, indent) = self.level_cell(depth);
|
||||
for (vi, name) in self.val_names.iter().enumerate() {
|
||||
let labels = vec![(col, Value::text(name.as_str()), indent, None)];
|
||||
let leaf = nodes.is_empty();
|
||||
out.push(Line { kind: LineKind::Item(!leaf), leaf, path: prefix.clone(), values: leaf, labels, vi: Some(vi) });
|
||||
if !leaf {
|
||||
self.flatten(nodes, depth, Some(vi), prefix, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flatten(&self, nodes: &[Node], depth: usize, vi: Option<usize>, prefix: &mut Vec<u32>, out: &mut Vec<Line>) {
|
||||
if self.values_here(depth, vi) {
|
||||
self.flatten_values(nodes, depth, prefix, out);
|
||||
return;
|
||||
}
|
||||
let n = self.rows.len();
|
||||
let outline = self.pt.layout == PivotLayout::Outline;
|
||||
// Σ Values still to come below this level: totals need one line per value field.
|
||||
let values_below = vi.is_none() && self.vpos.is_some();
|
||||
let (col, indent) = self.level_cell(depth + usize::from(vi.is_some() && self.vpos.is_some()));
|
||||
for node in nodes {
|
||||
prefix.push(node.item);
|
||||
let (v, fmt, label) = self.label_cell(depth, node.item);
|
||||
let (col, indent) = if outline { (depth, 0) } else { (0, depth.min(250) as u8) };
|
||||
let collapsed = depth + 1 < n && self.rows.get(depth).is_some_and(|f| is_collapsed(f, &label));
|
||||
if depth + 1 >= n || collapsed || node.children.is_empty() {
|
||||
let leaf = depth + 1 >= n || collapsed || node.children.is_empty();
|
||||
if leaf && !values_below {
|
||||
out.push(Line {
|
||||
kind: LineKind::Item(depth + 1 < n),
|
||||
leaf: true,
|
||||
path: prefix.clone(),
|
||||
values: true,
|
||||
labels: vec![(col, v, indent, fmt)],
|
||||
vi,
|
||||
});
|
||||
} else if leaf {
|
||||
out.push(Line {
|
||||
kind: LineKind::Item(true),
|
||||
leaf: false,
|
||||
path: prefix.clone(),
|
||||
values: false,
|
||||
labels: vec![(col, v, indent, fmt)],
|
||||
vi,
|
||||
});
|
||||
// Outline gives each level its own column, so the captions go in the Values
|
||||
// column even when the levels between are collapsed away.
|
||||
let level = if self.pt.layout == PivotLayout::Outline { self.vpos.unwrap_or(depth + 1) } else { depth + 1 };
|
||||
self.flatten_values(&[], level, prefix, out);
|
||||
} else if values_below {
|
||||
// Subtotals per value field, below the group (one line can't hold them all).
|
||||
out.push(Line {
|
||||
kind: LineKind::Item(true),
|
||||
leaf: false,
|
||||
path: prefix.clone(),
|
||||
values: false,
|
||||
labels: vec![(col, v, indent, fmt)],
|
||||
vi,
|
||||
});
|
||||
self.flatten(&node.children, depth + 1, None, prefix, out);
|
||||
if self.pt.subtotals {
|
||||
self.value_subtotals(col, indent, &label, prefix, out);
|
||||
}
|
||||
} else {
|
||||
let top = self.pt.subtotals && self.pt.subtotals_top;
|
||||
out.push(Line { kind: LineKind::Item(true), leaf: false, path: prefix.clone(), values: top, labels: vec![(col, v, indent, fmt)] });
|
||||
self.flatten(&node.children, depth + 1, prefix, out);
|
||||
out.push(Line {
|
||||
kind: LineKind::Item(true),
|
||||
leaf: false,
|
||||
path: prefix.clone(),
|
||||
values: top,
|
||||
labels: vec![(col, v, indent, fmt)],
|
||||
vi,
|
||||
});
|
||||
self.flatten(&node.children, depth + 1, vi, prefix, out);
|
||||
if self.pt.subtotals && !self.pt.subtotals_top {
|
||||
out.push(Line {
|
||||
kind: LineKind::Subtotal,
|
||||
@@ -615,6 +700,7 @@ impl Ctx<'_> {
|
||||
path: prefix.clone(),
|
||||
values: true,
|
||||
labels: vec![(col, Value::text(format!("{label} Total")), indent, None)],
|
||||
vi,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -622,7 +708,8 @@ impl Ctx<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn flatten_tabular(
|
||||
/// Tabular form of [`Self::flatten_values`]: the first line also carries the parents' labels.
|
||||
fn flatten_tabular_values(
|
||||
&self,
|
||||
nodes: &[Node],
|
||||
depth: usize,
|
||||
@@ -630,24 +717,57 @@ impl Ctx<'_> {
|
||||
pending: &[(usize, Value, u8, Option<String>)],
|
||||
out: &mut Vec<Line>,
|
||||
) {
|
||||
for (vi, name) in self.val_names.iter().enumerate() {
|
||||
let mut labels: Vec<(usize, Value, u8, Option<String>)> = if vi == 0 { pending.to_vec() } else { vec![] };
|
||||
labels.push((depth, Value::text(name.as_str()), 0, None));
|
||||
if nodes.is_empty() {
|
||||
out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels, vi: Some(vi) });
|
||||
} else {
|
||||
self.flatten_tabular(nodes, depth, Some(vi), prefix, &labels, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flatten_tabular(
|
||||
&self,
|
||||
nodes: &[Node],
|
||||
depth: usize,
|
||||
vi: Option<usize>,
|
||||
prefix: &mut Vec<u32>,
|
||||
pending: &[(usize, Value, u8, Option<String>)],
|
||||
out: &mut Vec<Line>,
|
||||
) {
|
||||
if self.values_here(depth, vi) {
|
||||
self.flatten_tabular_values(nodes, depth, prefix, pending, out);
|
||||
return;
|
||||
}
|
||||
let n = self.rows.len();
|
||||
let values_below = vi.is_none() && self.vpos.is_some();
|
||||
let col = depth + usize::from(vi.is_some() && self.vpos.is_some());
|
||||
for (i, node) in nodes.iter().enumerate() {
|
||||
prefix.push(node.item);
|
||||
let (v, fmt, label) = self.label_cell(depth, node.item);
|
||||
let mut labels: Vec<(usize, Value, u8, Option<String>)> = if i == 0 { pending.to_vec() } else { vec![] };
|
||||
labels.push((depth, v, 0, fmt));
|
||||
labels.push((col, v, 0, fmt));
|
||||
let collapsed = depth + 1 < n && self.rows.get(depth).is_some_and(|f| is_collapsed(f, &label));
|
||||
if depth + 1 >= n || collapsed || node.children.is_empty() {
|
||||
out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels });
|
||||
if values_below {
|
||||
self.flatten_tabular_values(&[], self.vpos.unwrap_or(depth + 1), prefix, &labels, out);
|
||||
} else {
|
||||
out.push(Line { kind: LineKind::Item(false), leaf: true, path: prefix.clone(), values: true, labels, vi });
|
||||
}
|
||||
} else {
|
||||
self.flatten_tabular(&node.children, depth + 1, prefix, &labels, out);
|
||||
if self.pt.subtotals {
|
||||
self.flatten_tabular(&node.children, depth + 1, vi, prefix, &labels, out);
|
||||
if self.pt.subtotals && values_below {
|
||||
self.value_subtotals(col, 0, &label, prefix, out);
|
||||
} else if self.pt.subtotals {
|
||||
out.push(Line {
|
||||
kind: LineKind::Subtotal,
|
||||
leaf: false,
|
||||
path: prefix.clone(),
|
||||
values: true,
|
||||
labels: vec![(depth, Value::text(format!("{label} Total")), 0, None)],
|
||||
labels: vec![(col, Value::text(format!("{label} Total")), 0, None)],
|
||||
vi,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -693,6 +813,12 @@ enum ValSrc {
|
||||
Calc(usize),
|
||||
}
|
||||
|
||||
/// True when `col_groups` distinct column leaves (plus the grand-total column) times `values` value fields
|
||||
/// (at least one data column per group) already exceed the sheet width. This is a lower bound on the final column count.
|
||||
fn data_cols_exceed_sheet(col_groups: usize, grand_total: bool, values: usize) -> bool {
|
||||
(col_groups + usize::from(grand_total)).saturating_mul(values.max(1)) > MAX_COLS as usize
|
||||
}
|
||||
|
||||
/// Computes a PivotTable report from the workbook.
|
||||
pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Output, String> {
|
||||
let src = read_source(wb, &pt.source, pivot_sheet)?;
|
||||
@@ -745,6 +871,14 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
||||
|
||||
let k = vals.len();
|
||||
let empty = rows.is_empty() && cols.is_empty() && vals.is_empty();
|
||||
// Σ Values in Rows (it exists with two or more value fields): its depth among the row fields
|
||||
// found in the source.
|
||||
let vpos: Option<usize> = (pt.values_on_rows && k > 1).then(|| {
|
||||
let p = pt.values_position.unwrap_or(pt.rows.len());
|
||||
pt.rows.iter().take(p).filter(|f| src.col(&f.source_col).is_some()).count()
|
||||
});
|
||||
let on_rows = vpos.is_some();
|
||||
let row_levels = rows.len() + usize::from(on_rows);
|
||||
|
||||
// ---- aggregate
|
||||
let mut row_items: Vec<FieldItems> = rows.iter().map(|_| FieldItems::default()).collect();
|
||||
@@ -752,6 +886,7 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
||||
let mut aggs: AggMap = HashMap::new();
|
||||
let mut row_set: HashSet<Vec<u32>> = HashSet::new();
|
||||
let mut col_set: HashSet<Vec<u32>> = HashSet::new();
|
||||
let mut col_leaves: HashSet<Vec<u32>> = HashSet::new();
|
||||
let filter_fmts: Vec<String> = filters.iter().map(|f| fmt_of(f.0)).collect();
|
||||
let row_fmts: Vec<String> = rows.iter().map(|(c, _)| fmt_of(*c)).collect();
|
||||
let col_fmts: Vec<String> = cols.iter().map(|(c, _)| fmt_of(*c)).collect();
|
||||
@@ -788,8 +923,22 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
||||
}
|
||||
}
|
||||
}
|
||||
// The column leaf this record lands in: its path cut after the first collapsed item (as in `flatten_cols`).
|
||||
let shown = cols
|
||||
.iter()
|
||||
.zip(col_items.iter())
|
||||
.zip(cp.iter())
|
||||
.position(|(((_, f), fi), &it)| !f.collapsed_items.is_empty() && is_collapsed(f, &fi.label(it)))
|
||||
.map_or(cp.len(), |d| d + 1);
|
||||
if !col_leaves.contains(&cp[..shown]) {
|
||||
col_leaves.insert(cp[..shown].to_vec());
|
||||
}
|
||||
row_set.insert(rp);
|
||||
col_set.insert(cp);
|
||||
// Refuse a report that cannot fit before the accumulators for more column groups are allocated.
|
||||
if !cols.is_empty() && data_cols_exceed_sheet(col_leaves.len(), pt.grand_totals_rows, k) {
|
||||
return Err("The PivotTable report will not fit on the sheet.".into());
|
||||
}
|
||||
if aggs.len() > 5_000_000 {
|
||||
return Err("The PivotTable report is too large.".into());
|
||||
}
|
||||
@@ -806,24 +955,32 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
||||
collect_siblings(&row_tree, &mut Vec::new(), &mut siblings);
|
||||
let level0: Vec<u32> = row_tree.iter().map(|n| n.item).collect();
|
||||
|
||||
let ctx = Ctx { pt, rows: row_fields.clone(), row_items, row_fmts: row_fmts.clone() };
|
||||
let val_names: Vec<String> = vals.iter().map(|(_, v)| v.name.clone()).collect();
|
||||
let ctx = Ctx { pt, rows: row_fields.clone(), row_items, row_fmts: row_fmts.clone(), vpos, val_names: val_names.clone() };
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
if rows.is_empty() {
|
||||
lines.push(Line { kind: LineKind::Item(false), leaf: true, path: vec![], values: true, labels: vec![] });
|
||||
if row_levels == 0 {
|
||||
lines.push(Line { kind: LineKind::Item(false), leaf: true, path: vec![], values: true, labels: vec![], vi: None });
|
||||
} else {
|
||||
if pt.layout == PivotLayout::Tabular {
|
||||
ctx.flatten_tabular(&row_tree, 0, &mut Vec::new(), &[], &mut lines);
|
||||
ctx.flatten_tabular(&row_tree, 0, None, &mut Vec::new(), &[], &mut lines);
|
||||
} else {
|
||||
ctx.flatten(&row_tree, 0, &mut Vec::new(), &mut lines);
|
||||
ctx.flatten(&row_tree, 0, None, &mut Vec::new(), &mut lines);
|
||||
}
|
||||
if pt.grand_totals_cols {
|
||||
lines.push(Line {
|
||||
// With only Σ Values in Rows its items are the totals already.
|
||||
if pt.grand_totals_cols && !rows.is_empty() {
|
||||
let grand = |text: String, vi: Option<usize>| Line {
|
||||
kind: LineKind::Grand,
|
||||
leaf: false,
|
||||
path: vec![],
|
||||
values: true,
|
||||
labels: vec![(0, Value::text("Grand Total"), 0, None)],
|
||||
});
|
||||
labels: vec![(0, Value::text(text), 0, None)],
|
||||
vi,
|
||||
};
|
||||
if on_rows {
|
||||
lines.extend(val_names.iter().enumerate().map(|(vi, name)| grand(format!("Total {name}"), Some(vi))));
|
||||
} else {
|
||||
lines.push(grand("Grand Total".into(), None));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut specs: Vec<ColSpec> = Vec::new();
|
||||
@@ -837,7 +994,10 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
||||
}
|
||||
let mut data_cols: Vec<(usize, Option<usize>)> = Vec::new();
|
||||
for (si, _) in specs.iter().enumerate() {
|
||||
if k == 0 {
|
||||
if on_rows {
|
||||
// Each line names its value field; one data column per column item.
|
||||
data_cols.push((si, None));
|
||||
} else if k == 0 {
|
||||
if !cols.is_empty() {
|
||||
data_cols.push((si, None));
|
||||
}
|
||||
@@ -852,12 +1012,12 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
||||
}
|
||||
|
||||
let compact = pt.layout == PivotLayout::Compact;
|
||||
let l = if rows.is_empty() && cols.is_empty() {
|
||||
let l = if row_levels == 0 && cols.is_empty() {
|
||||
0
|
||||
} else if compact {
|
||||
1
|
||||
} else {
|
||||
rows.len().max(1)
|
||||
row_levels.max(1)
|
||||
};
|
||||
let mut width = l + data_cols.len();
|
||||
if !shown_filters.is_empty() {
|
||||
@@ -903,14 +1063,19 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
||||
let val_name = |vi: usize| vals.get(vi).map(|(_, v)| v.name.clone()).unwrap_or_default();
|
||||
let headers_on = pt.show_headers;
|
||||
let row_header = |g: &mut Grid, r: usize| {
|
||||
if rows.is_empty() || !headers_on {
|
||||
if row_levels == 0 || !headers_on {
|
||||
return;
|
||||
}
|
||||
if compact {
|
||||
g.set(r, 0, Value::text("Row Labels"), CellKind::Header, 0, None);
|
||||
} else {
|
||||
for (i, f) in row_fields.iter().enumerate() {
|
||||
g.set(r, i, Value::text(f.source_col.as_str()), CellKind::Header, 0, None);
|
||||
// One column per row field, Σ Values ("Values") at its place.
|
||||
let mut names: Vec<&str> = row_fields.iter().map(|f| f.source_col.as_str()).collect();
|
||||
if let Some(p) = vpos {
|
||||
names.insert(p.min(names.len()), "Values");
|
||||
}
|
||||
for (i, name) in names.into_iter().enumerate() {
|
||||
g.set(r, i, Value::text(name), CellKind::Header, 0, None);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -920,8 +1085,11 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
||||
let r = g.push_row(RowKind::Header)?;
|
||||
row_header(&mut g, r);
|
||||
for (j, (_, vi)) in data_cols.iter().enumerate() {
|
||||
if let Some(vi) = vi {
|
||||
g.set(r, l + j, Value::text(val_name(*vi)), CellKind::Header, 0, None);
|
||||
match vi {
|
||||
Some(vi) => g.set(r, l + j, Value::text(val_name(*vi)), CellKind::Header, 0, None),
|
||||
// Values on rows, no column field: the one data column is the grand total.
|
||||
None if on_rows => g.set(r, l + j, Value::text("Total"), CellKind::Header, 0, None),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -933,7 +1101,7 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
||||
let caption = if compact { "Column Labels".to_string() } else { col_fields.first().map(|f| f.source_col.clone()).unwrap_or_default() };
|
||||
g.set(cap, l, Value::text(caption), CellKind::Header, 0, None);
|
||||
}
|
||||
let levels = cols.len() + usize::from(k > 1);
|
||||
let levels = cols.len() + usize::from(k > 1 && !on_rows);
|
||||
let first_level = g.rows.len();
|
||||
for _ in 0..levels {
|
||||
g.push_row(RowKind::Header)?;
|
||||
@@ -1048,17 +1216,19 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
||||
&& (k > 1 || parts.is_empty())
|
||||
{
|
||||
parts.push(val_name(*vi));
|
||||
} else if parts.is_empty() {
|
||||
parts.push("Total".into());
|
||||
}
|
||||
Some(((l + j) as u32, parts.join(" - ")))
|
||||
})
|
||||
.filter(|_| k > 0)
|
||||
.collect();
|
||||
let label_col = if rows.is_empty() {
|
||||
let label_col = if row_levels == 0 {
|
||||
None
|
||||
} else if compact {
|
||||
Some(0)
|
||||
} else {
|
||||
Some(rows.len().saturating_sub(1) as u32)
|
||||
Some(row_levels.saturating_sub(1) as u32)
|
||||
};
|
||||
for line in &lines {
|
||||
let rk = match line.kind {
|
||||
@@ -1082,8 +1252,9 @@ pub fn compute(wb: &Workbook, pt: &PivotTable, pivot_sheet: usize) -> Result<Out
|
||||
continue;
|
||||
}
|
||||
let rp = line.path.as_slice();
|
||||
for (j, (si, vi)) in data_cols.iter().enumerate() {
|
||||
let (Some(spec), Some(vi)) = (specs.get(*si), vi) else { continue };
|
||||
for (j, (si, col_vi)) in data_cols.iter().enumerate() {
|
||||
let line_vi = col_vi.or(line.vi);
|
||||
let (Some(spec), Some(vi)) = (specs.get(*si), &line_vi) else { continue };
|
||||
let Some((_, vdef)) = vals.get(*vi) else { continue };
|
||||
let cp = spec.path.as_slice();
|
||||
let Some(raw) = get(rp, cp, *vi) else { continue };
|
||||
@@ -1428,3 +1599,17 @@ pub fn clear_output(wb: &mut Workbook, sheet: usize, idx: usize) {
|
||||
p.last_range = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fit_tests {
|
||||
use super::data_cols_exceed_sheet;
|
||||
|
||||
#[test]
|
||||
fn column_budget_counts_value_fields_and_grand_total() {
|
||||
assert!(!data_cols_exceed_sheet(64, true, 128));
|
||||
assert!(data_cols_exceed_sheet(64, true, 256));
|
||||
assert!(!data_cols_exceed_sheet(64, false, 256));
|
||||
assert!(!data_cols_exceed_sheet(16_384, false, 0));
|
||||
assert!(data_cols_exceed_sheet(16_385, false, 0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,13 @@ impl Selection {
|
||||
pub fn is_single_cell(&self) -> bool {
|
||||
self.ranges.len() == 1 && self.current().is_single()
|
||||
}
|
||||
/// The corner of the current area opposite the anchor: the end that Shift+arrows move.
|
||||
pub fn moving_end(&self) -> CellRef {
|
||||
let r = self.current();
|
||||
let row = if self.anchor.row == r.start.row { r.end.row } else { r.start.row };
|
||||
let col = if self.anchor.col == r.start.col { r.end.col } else { r.start.col };
|
||||
CellRef::new(row, col)
|
||||
}
|
||||
/// Extends the current area from the anchor to `to` (Shift+click / Shift+arrows).
|
||||
pub fn extend_to(&mut self, to: CellRef) {
|
||||
let r = RangeRef::new(self.anchor, to);
|
||||
|
||||
@@ -253,6 +253,22 @@ fn undo_redo() {
|
||||
assert!(s.doc().unwrap().is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redo_selects_the_redone_cell() {
|
||||
// #1855: type into A1..A3, click A1, undo, redo: the selection follows the edit to A3.
|
||||
let mut s = s();
|
||||
for (cell, text) in [("A1", "first"), ("A2", "second"), ("A3", "third")] {
|
||||
s.execute("selection.set", json!({"cell": cell})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": cell, "input": text})).unwrap();
|
||||
}
|
||||
s.execute("selection.set", json!({"cell": "A1"})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.a1(), "A3");
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(v(&s, "A3"), Value::from("third"));
|
||||
assert_eq!(s.doc().unwrap().selection.a1(), "A3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_text_edit_undo_redo_and_noop() {
|
||||
let mut s = s();
|
||||
@@ -560,6 +576,28 @@ fn insert_delete_rows_adjust() {
|
||||
assert_eq!(v(&s, "A1"), Value::Number(10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_multi_area_row_selection_removes_every_area_and_undoes_once() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3], [4], [5], [6]]})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "B1", "input": "=A6"})).unwrap();
|
||||
// Ctrl-select rows 2, 4 and 5:6 (A1 refs are 1-based).
|
||||
let sel = &mut s.doc_mut().unwrap().selection;
|
||||
sel.ranges = vec![gridcraft_core::RangeRef::rows(1, 1), gridcraft_core::RangeRef::rows(3, 3), gridcraft_core::RangeRef::rows(4, 5)];
|
||||
sel.active = CellRef::new(4, 0);
|
||||
s.doc_mut().unwrap().undo.clear();
|
||||
s.execute("home.deleteRows", json!({})).unwrap();
|
||||
assert_eq!((v(&s, "A1"), v(&s, "A2"), v(&s, "A3")), (Value::Number(1.0), Value::Number(3.0), Value::Empty));
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "B1"})).unwrap()["formula"], "=#REF!");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!((1..=6).map(|r| v(&s, &format!("A{r}"))).collect::<Vec<_>>(), (1..=6).map(|n| Value::Number(n as f64)).collect::<Vec<_>>());
|
||||
assert!(s.doc().unwrap().undo.is_empty(), "one undo step restored all rows");
|
||||
// The explicit list form behaves the same, for columns too.
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1, 2, 3, 4]]})).unwrap();
|
||||
s.execute("home.deleteColumns", json!({"cols": "A:A,C:C"})).unwrap();
|
||||
assert_eq!((v(&s, "A1"), v(&s, "B1"), v(&s, "C1")), (Value::Number(2.0), Value::Number(4.0), Value::Empty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_columns_reorders_without_overwriting_and_undoes_once() {
|
||||
let mut s = s();
|
||||
@@ -805,6 +843,46 @@ fn sort_and_filter() {
|
||||
assert_eq!(r["hiddenRows"], 1);
|
||||
}
|
||||
|
||||
/// Top 10 keeps the top or bottom N items or percent, ties at the threshold included.
|
||||
#[test]
|
||||
fn filter_top10_items_and_percent() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Score"], [5], [1], [9], [3], [9], [7], ["x"]]})).unwrap();
|
||||
s.execute("selection.set", json!({"cell": "A1"})).unwrap();
|
||||
s.execute("data.filter", json!({})).unwrap();
|
||||
// Top 2 items: 9, 9 (the "x" text row is hidden too).
|
||||
let r = s.execute("data.filterBy", json!({"column": "A", "top": {"count": 2}})).unwrap();
|
||||
assert_eq!(r["hiddenRows"], 5);
|
||||
// Bottom 2 items: 1, 3.
|
||||
let r = s.execute("data.filterBy", json!({"column": "A", "top": {"count": 2, "bottom": true}})).unwrap();
|
||||
assert_eq!(r["hiddenRows"], 5);
|
||||
// Top 50 percent of 6 numbers = 3 items; the threshold 7 keeps 9, 9, 7.
|
||||
let r = s.execute("data.filterBy", json!({"column": "A", "top": {"count": 50, "percent": true}})).unwrap();
|
||||
assert_eq!(r["hiddenRows"], 4);
|
||||
}
|
||||
|
||||
/// Two Top 10 criteria on one column (possible in a native JSON file) each keep their own
|
||||
/// cutoff: Top 3 and Bottom 4 of 1..5 keep 3 and 4.
|
||||
#[test]
|
||||
fn filter_top10_two_criteria_on_one_column() {
|
||||
use gridcraft_model::FilterCriterion;
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["N"], [1], [2], [3], [4], [5]]})).unwrap();
|
||||
s.execute("selection.set", json!({"cell": "A1"})).unwrap();
|
||||
s.execute("data.filter", json!({})).unwrap();
|
||||
let sh = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb).sheet_mut(0).unwrap();
|
||||
if let Some(af) = sh.autofilter.as_mut() {
|
||||
af.criteria = vec![
|
||||
(0, FilterCriterion::Top10 { bottom: false, percent: false, count: 3 }),
|
||||
(0, FilterCriterion::Top10 { bottom: true, percent: false, count: 4 }),
|
||||
];
|
||||
}
|
||||
s.execute("data.reapply", json!({})).unwrap();
|
||||
let hidden: Vec<u32> = s.active().unwrap().wb.sheet(0).unwrap().rows.iter().filter(|(_, r)| r.hidden).map(|(k, _)| *k).collect();
|
||||
// Rows are 0-based: values 1, 2 and 5 sit on rows 1, 2 and 5.
|
||||
assert_eq!(hidden, vec![1, 2, 5]);
|
||||
}
|
||||
|
||||
/// Turning the filter on with only the header row selected filters the data below it (#201).
|
||||
#[test]
|
||||
fn filter_on_a_selected_header_row_covers_the_data_below() {
|
||||
@@ -1007,6 +1085,87 @@ fn spill_references_roundtrip_through_xlsx() {
|
||||
assert_eq!((v(&s, "C1"), v(&s, "C2")), (Value::Number(10.0), Value::Number(4.0)));
|
||||
}
|
||||
|
||||
/// Fixture for the open-time row fit (issue #833): `row_info` = (size, custom, hidden) for
|
||||
/// row 5 (index 4), `None` = no row entry at all. Cell A5 = "plain big bold" — whole-cell
|
||||
/// font 20pt with `big_font`, plain 11pt without (a tall plain row's fit removes its entry).
|
||||
/// 11pt cell in A1 for contrast rows. Built with the model API, never through commands —
|
||||
/// commands would auto-fit the rows — and exported with `write_xlsx`, which omits `ht` for
|
||||
/// rows without a size: exactly the bug's input.
|
||||
fn big_font_workbook(row_info: Option<(f32, bool, bool)>, big_font: bool) -> Vec<u8> {
|
||||
let mut wb = gridcraft_model::Workbook::new();
|
||||
let big = wb.styles.derive(gridcraft_model::StyleId::DEFAULT, |s| s.font.size = 20.0);
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
sh.set_value(CellRef::new(0, 0), Value::text("small"));
|
||||
sh.set_value(CellRef::new(4, 0), Value::text("plain big bold"));
|
||||
if big_font {
|
||||
sh.set_style(CellRef::new(4, 0), big);
|
||||
}
|
||||
if let Some((size, custom, hidden)) = row_info {
|
||||
let e = sh.rows.entry(4).or_default();
|
||||
e.size = Some(size);
|
||||
e.custom = custom;
|
||||
e.hidden = hidden;
|
||||
}
|
||||
gridcraft_xlsx::write_xlsx(&wb).unwrap()
|
||||
}
|
||||
|
||||
#[test] // A — the issue's repro
|
||||
fn open_grows_default_height_rows_to_their_font() {
|
||||
let default_h = gridcraft_model::Workbook::new().active().unwrap().row_height(0);
|
||||
let mut s = Session::new();
|
||||
s.execute("file.open", json!({"name": "bigfont.xlsx", "base64": crate::io::base64_encode(&big_font_workbook(None, true))})).unwrap();
|
||||
let h = s.doc().unwrap().wb.active().unwrap().row_height(4);
|
||||
assert!(h >= 30.0, "20pt cell must grow its default-height row on open, got {h}");
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().row_height(0), default_h, "11pt row stays default");
|
||||
}
|
||||
|
||||
#[test] // B — Review Focus 1
|
||||
fn open_keeps_custom_height_rows() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.open", json!({"name": "custom.xlsx", "base64": crate::io::base64_encode(&big_font_workbook(Some((25.0, true, false)), true))}))
|
||||
.unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().row_height(4), 25.0, "custom-height rows are left alone on open");
|
||||
}
|
||||
|
||||
#[test] // C — Review Focus 2 (Some→Some: the fit shrinks the row, the clamp keeps the taller height)
|
||||
fn open_never_shrinks_taller_auto_rows() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.open", json!({"name": "tall.xlsx", "base64": crate::io::base64_encode(&big_font_workbook(Some((60.0, false, false)), true))}))
|
||||
.unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().row_height(4), 60.0, "open is grow-only: a 60px auto-height row keeps its height");
|
||||
}
|
||||
|
||||
#[test] // C′ — Review Focus 2 (Some→None: a plain tall row's entry is removed by the fit, the clamp restores it)
|
||||
fn open_restores_height_of_plain_rows_the_fit_removed() {
|
||||
let mut s = Session::new();
|
||||
s.execute(
|
||||
"file.open",
|
||||
json!({"name": "tall-plain.xlsx", "base64": crate::io::base64_encode(&big_font_workbook(Some((60.0, false, false)), false))}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().row_height(4), 60.0, "a tall plain row keeps its height after the fit removes its entry");
|
||||
}
|
||||
|
||||
#[test] // D — Review Focus 5
|
||||
fn open_fitting_keeps_hidden_rows_hidden() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.open", json!({"name": "hidden.xlsx", "base64": crate::io::base64_encode(&big_font_workbook(Some((20.0, false, true)), true))}))
|
||||
.unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
let info = sh.rows.get(&4).unwrap();
|
||||
assert!(info.size.is_some_and(|h| h >= 20.0), "hidden row grows or keeps its height, got {:?}", info.size);
|
||||
assert!(info.hidden, "fitting a hidden row must not unhide it");
|
||||
}
|
||||
|
||||
#[test] // E — Review Focus 4
|
||||
fn open_leaves_plain_workbooks_untouched() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.open", json!({"name": "plain.xlsx", "base64": crate::io::base64_encode(&big_font_workbook(None, false))})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert!(sh.rows.get(&0).is_none_or(|i| i.size.is_none()), "11pt default rows get no height entry");
|
||||
assert!(sh.rows.values().all(|i| i.size.is_none()), "no row gains a height entry");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_and_structured_refs() {
|
||||
let mut s = s();
|
||||
@@ -1018,6 +1177,71 @@ fn tables_and_structured_refs() {
|
||||
assert_eq!(v(&s, "B4"), Value::Number(7.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_refs_to_columns_with_edge_spaces() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Item", "Qty "], ["a", 2], ["b", 3]]})).unwrap();
|
||||
s.execute("insert.table", json!({"range": "A1:B3", "hasHeaders": true})).unwrap();
|
||||
s.execute("table.totalRow", json!({"on": true, "table": "Table1"})).unwrap();
|
||||
assert_eq!(v(&s, "B4"), Value::Number(5.0));
|
||||
for (cell, input) in [
|
||||
("F1", "=SUM(Table1[Qty ])"),
|
||||
("F2", "=Table1[[#Totals],[Qty ]]"),
|
||||
("F3", "=SUBTOTAL(109,Table1[Qty ])"),
|
||||
("F4", "=SUM(Table1[[Qty ]])"),
|
||||
("F5", "=SUM(Table1[Qty])"),
|
||||
("C2", "=Table1[@[Qty ]]*10"),
|
||||
] {
|
||||
s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap();
|
||||
}
|
||||
let n = Value::Number;
|
||||
assert_eq!([v(&s, "F1"), v(&s, "F2"), v(&s, "F3"), v(&s, "F4")], [n(5.0), n(5.0), n(5.0), n(5.0)]);
|
||||
assert_eq!(v(&s, "F5"), Value::Error(gridcraft_core::CellError::Ref));
|
||||
assert_eq!(v(&s, "C2"), n(20.0));
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "F3"})).unwrap()["formula"], "=SUBTOTAL(109,Table1[[Qty ]])");
|
||||
|
||||
let r = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
|
||||
let b64 = r["base64"].as_str().unwrap().to_string();
|
||||
s.execute("file.open", json!({"name": "x.xlsx", "base64": b64})).unwrap();
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "F3"})).unwrap()["formula"], "=SUBTOTAL(109,Table1[[Qty ]])");
|
||||
assert_eq!([v(&s, "F1"), v(&s, "F2"), v(&s, "F3")], [n(5.0), n(5.0), n(5.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_rename_keeps_a_defined_name_with_the_same_name() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Qty"], [7]]})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "D1", "input": "100"})).unwrap();
|
||||
s.execute("insert.table", json!({"range": "A1:A2", "header": true, "name": "Data"})).unwrap();
|
||||
s.execute("formulas.defineName", json!({"name": "Data", "refersTo": "=Sheet1!$D$1"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "E1", "input": "=Data"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "F1", "input": "=SUM(Data[Qty])"})).unwrap();
|
||||
s.execute("table.rename", json!({"table": "Data", "name": "Later"})).unwrap();
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "E1"})).unwrap()["formula"], "=Data");
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "F1"})).unwrap()["formula"], "=SUM(Later[Qty])");
|
||||
assert_eq!((v(&s, "E1"), v(&s, "F1")), (Value::Number(100.0), Value::Number(7.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_names_are_limited_to_255_characters() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Qty"], [7]]})).unwrap();
|
||||
s.execute("insert.table", json!({"range": "A1:A2", "header": true, "name": "Original"})).unwrap();
|
||||
|
||||
let name_255 = "ع".repeat(255);
|
||||
s.execute("table.rename", json!({"table": "Original", "name": name_255})).unwrap();
|
||||
assert!(s.doc().unwrap().wb.table(&"ع".repeat(255)).is_some());
|
||||
|
||||
let name_256 = "ع".repeat(256);
|
||||
let error = s.execute("table.rename", json!({"table": &"ع".repeat(255), "name": name_256})).unwrap_err();
|
||||
assert!(error.to_string().contains("not valid"), "{error}");
|
||||
assert!(s.doc().unwrap().wb.table(&"ع".repeat(255)).is_some());
|
||||
|
||||
let name_200 = "ع".repeat(200);
|
||||
s.execute("table.rename", json!({"table": &"ع".repeat(255), "name": name_200})).unwrap();
|
||||
assert!(s.doc().unwrap().wb.table(&"ع".repeat(200)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slicer_filters_table() {
|
||||
let mut s = s();
|
||||
@@ -1170,6 +1394,22 @@ fn insert_and_delete_cells_never_shift_part_of_a_table() {
|
||||
assert!(e.to_string().contains("shift cells in a table"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_names_with_a_hyphen_are_rejected() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Qty"], [7]]})).unwrap();
|
||||
let e = s.execute("insert.table", json!({"range": "A1:A2", "header": true, "name": "A-B"})).unwrap_err();
|
||||
assert!(e.to_string().contains("not valid"), "{e}");
|
||||
assert!(s.doc().unwrap().wb.table("A-B").is_none());
|
||||
s.execute("insert.table", json!({"range": "A1:A2", "header": true, "name": "Sales"})).unwrap();
|
||||
let e = s.execute("table.rename", json!({"table": "Sales", "name": "A-B"})).unwrap_err();
|
||||
assert!(e.to_string().contains("not valid"), "{e}");
|
||||
assert!(s.doc().unwrap().wb.table("Sales").is_some());
|
||||
assert!(s.execute("table.rename", json!({"table": "Sales", "name": ".AB"})).is_err());
|
||||
s.execute("table.rename", json!({"table": "Sales", "name": "A_B"})).unwrap();
|
||||
assert!(s.doc().unwrap().wb.table("A_B").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_and_redo_keep_the_watch_window_list() {
|
||||
// Found by the command-sequence fuzzer: the list lives in the workbook but isn't an edit, so
|
||||
@@ -1519,6 +1759,141 @@ fn saving_an_opened_semicolon_csv_keeps_semicolons() {
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// A workbook whose B1 is `=[1]Sheet1!A1`, linked to `link` (an external relationship target).
|
||||
fn linked_workbook(link: &str) -> Vec<u8> {
|
||||
use std::io::{Cursor, Write};
|
||||
const NS_REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
||||
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/externalLinks/externalLink1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.externalLink+xml"/></Types>"#.to_string();
|
||||
let rels = format!(
|
||||
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="{NS_REL}/officeDocument" Target="xl/workbook.xml"/></Relationships>"#
|
||||
);
|
||||
let book = format!(
|
||||
r#"<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="{NS_REL}"><sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets><externalReferences><externalReference r:id="rId2"/></externalReferences></workbook>"#
|
||||
);
|
||||
let book_rels = format!(
|
||||
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="{NS_REL}/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="{NS_REL}/externalLink" Target="externalLinks/externalLink1.xml"/></Relationships>"#
|
||||
);
|
||||
let sheet = r#"<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData><row r="1"><c r="B1"><f>[1]Sheet1!A1</f><v>10</v></c></row></sheetData></worksheet>"#.to_string();
|
||||
let link_part = format!(
|
||||
r#"<externalLink xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="{NS_REL}"><externalBook r:id="rId1"><sheetNames><sheetName val="Sheet1"/></sheetNames><sheetDataSet><sheetData sheetId="0"><row r="1"><cell r="A1"><v>10</v></cell></row></sheetData></sheetDataSet></externalBook></externalLink>"#
|
||||
);
|
||||
let link_rels = format!(
|
||||
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="{NS_REL}/externalLinkPath" Target="{link}" TargetMode="External"/></Relationships>"#
|
||||
);
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for (name, data) in [
|
||||
("[Content_Types].xml", types),
|
||||
("_rels/.rels", rels),
|
||||
("xl/workbook.xml", book),
|
||||
("xl/_rels/workbook.xml.rels", book_rels),
|
||||
("xl/worksheets/sheet1.xml", sheet),
|
||||
("xl/externalLinks/externalLink1.xml", link_part),
|
||||
("xl/externalLinks/_rels/externalLink1.xml.rels", link_rels),
|
||||
] {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(data.as_bytes()).unwrap();
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// The target of the linked workbook in a saved file.
|
||||
fn link_target(path: &std::path::Path) -> String {
|
||||
use std::io::Read;
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(std::fs::read(path).unwrap())).unwrap();
|
||||
let mut rels = String::new();
|
||||
zip.by_name("xl/externalLinks/_rels/externalLink1.xml.rels").unwrap().read_to_string(&mut rels).unwrap();
|
||||
let (_, after) = rels.split_once("Target=\"").unwrap();
|
||||
after.split_once('"').unwrap().0.to_string()
|
||||
}
|
||||
|
||||
/// Save As into another folder keeps the link on the same file, as Excel does: the relative path
|
||||
/// is rewritten from the new folder (#801).
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn save_as_into_another_folder_keeps_linked_workbooks_on_the_same_file() {
|
||||
#[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name
|
||||
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let a = std::env::temp_dir().join(format!("gridcraft-links-{}-{nonce}", std::process::id()));
|
||||
let b = a.join("b");
|
||||
std::fs::create_dir_all(&b).unwrap();
|
||||
let dest = a.join("Dest.xlsx");
|
||||
std::fs::write(&dest, linked_workbook("Source.xlsx")).unwrap();
|
||||
let mut s = s();
|
||||
s.execute("file.open", json!({"path": dest})).unwrap();
|
||||
s.execute("file.saveAs", json!({"path": b.join("Dest.xlsx")})).unwrap();
|
||||
assert_eq!(link_target(&b.join("Dest.xlsx")), "../Source.xlsx");
|
||||
// Saved again into the folder it was read from, the path is the one it was read with.
|
||||
s.execute("file.saveAs", json!({"path": a.join("Copy.xlsx")})).unwrap();
|
||||
assert_eq!(link_target(&a.join("Copy.xlsx")), "Source.xlsx");
|
||||
// Read from a subfolder and saved into its parent, the path goes down into the subfolder.
|
||||
let deep = b.join("Deep.xlsx");
|
||||
std::fs::write(&deep, linked_workbook("Source.xlsx")).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.open", json!({"path": deep})).unwrap();
|
||||
s.execute("file.saveAs", json!({"path": a.join("Deep.xlsx")})).unwrap();
|
||||
assert_eq!(link_target(&a.join("Deep.xlsx")), "b/Source.xlsx");
|
||||
std::fs::remove_dir_all(a).unwrap();
|
||||
}
|
||||
|
||||
/// A typo like `report.cvs` must not quietly write an XLSX under that name and adopt it as the
|
||||
/// document's file.
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn saving_to_an_unknown_extension_is_refused() {
|
||||
#[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name
|
||||
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-unknown-ext-{}-{nonce}", std::process::id()));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap();
|
||||
for name in ["report.cvs", "report"] {
|
||||
let path = dir.join(name);
|
||||
let e = s.execute("file.save", json!({"path": path})).unwrap_err().to_string();
|
||||
assert!(e.contains(".xlsx"), "{name}: {e}");
|
||||
assert!(!path.exists(), "{name} was written");
|
||||
}
|
||||
let d = s.doc().unwrap();
|
||||
assert!(d.path.is_none());
|
||||
assert!(d.is_dirty());
|
||||
assert!(s.execute("file.saveBytes", json!({"format": "foo"})).is_err());
|
||||
s.execute("file.save", json!({"path": dir.join("report.csv")})).unwrap();
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// An XLSX opened by its content under an unknown extension (`budget.dat`) saves back to its own
|
||||
/// file as XLSX; a Save As to another unknown extension is still refused.
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn an_xlsx_opened_under_an_unknown_extension_saves_back_as_xlsx() {
|
||||
#[allow(clippy::disallowed_methods)] // native-only test: a unique temp dir name
|
||||
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-dat-{}-{nonce}", std::process::id()));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let path = dir.join("budget.dat");
|
||||
let mut src = s();
|
||||
src.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap();
|
||||
std::fs::write(&path, crate::io::save_bytes(&src.doc().unwrap().wb, "book.xlsx").unwrap()).unwrap();
|
||||
let mut s = s();
|
||||
s.execute("file.open", json!({"path": path})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "2"})).unwrap();
|
||||
s.execute("file.save", json!({})).unwrap();
|
||||
assert!(!s.doc().unwrap().is_dirty());
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
assert_eq!(gridcraft_xlsx::sniff(&bytes), gridcraft_xlsx::Format::Xlsx);
|
||||
let (wb, _) = crate::io::open_bytes("book.xlsx", &bytes).unwrap();
|
||||
assert_eq!(wb.active().unwrap().value(CellRef::parse("A1").unwrap()), Value::Number(2.0));
|
||||
assert!(s.execute("file.saveAs", json!({"path": dir.join("copy.foo")})).is_err());
|
||||
assert!(!dir.join("copy.foo").exists());
|
||||
// A text file under an unknown extension opens as CSV: Save doesn't turn it into an XLSX.
|
||||
let notes = dir.join("notes.dat");
|
||||
std::fs::write(¬es, "a,b\r\n").unwrap();
|
||||
s.execute("file.open", json!({"path": notes})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "c"})).unwrap();
|
||||
assert!(s.execute("file.save", json!({})).is_err());
|
||||
assert_eq!(std::fs::read_to_string(¬es).unwrap(), "a,b\r\n");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validation_formulas_are_relative_and_see_the_new_value() {
|
||||
let mut s = s();
|
||||
@@ -1617,6 +1992,17 @@ fn replacing_a_spilling_formula_clears_its_spill() {
|
||||
assert_eq!((v(&s, "A2"), v(&s, "B1")), (Value::Empty, Value::Number(0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_a_spill_past_65536_cells_updates_a_late_dependent() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "=SEQUENCE(65537)"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C1", "input": "=A65536"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C2", "input": "=A65537"})).unwrap();
|
||||
assert_eq!(v(&s, "C2"), Value::Number(65537.0));
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "5"})).unwrap();
|
||||
assert_eq!((v(&s, "C1"), v(&s, "C2")), (Value::Number(0.0), Value::Number(0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_ending_in_index_recalculates() {
|
||||
let mut s = s();
|
||||
@@ -1696,6 +2082,23 @@ fn every_command_survives_empty_params() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_object_params_do_not_panic_in_wrapper_commands() {
|
||||
// Issue #516.
|
||||
for cmd in ["pageLayout.themeColors", "pageLayout.background", "home.drawBorder"] {
|
||||
for params in [json!([]), json!(5), json!("x"), json!(true)] {
|
||||
let mut s = s();
|
||||
let r = s.execute(cmd, params.clone());
|
||||
assert!(!matches!(r, Err(crate::EngineError::Internal(..))), "{cmd} {params}: {r:?}");
|
||||
}
|
||||
}
|
||||
// A saved script step forwards its params unchanged into the same call.
|
||||
let mut s = s();
|
||||
s.execute("automate.newScript", json!({"name": "bad", "commands": [{"command": "home.drawBorder", "params": []}]})).unwrap();
|
||||
let r = s.execute("automate.runScript", json!({"name": "bad"}));
|
||||
assert!(!matches!(r, Err(crate::EngineError::Internal(..))), "script: {r:?}");
|
||||
}
|
||||
|
||||
fn fmt_of(s: &Session, a: &str) -> String {
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
let sh = wb.active().unwrap();
|
||||
@@ -2505,6 +2908,15 @@ fn fill_series_month_clamps_to_month_end() {
|
||||
assert_eq!(v(&s, "A4"), ymd(2026, 4, 15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_series_weekday_out_of_range_step_errors() {
|
||||
let mut s = s();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "45000"})).unwrap();
|
||||
let r = s.execute("edit.fillSeries", json!({"range": "A1:A2", "direction": "columns", "type": "date", "dateUnit": "weekday", "step": 1e12}));
|
||||
assert!(r.is_err());
|
||||
assert_eq!(v(&s, "A2"), Value::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_series_month_leap_year() {
|
||||
let s = series_date("2028-01-31", "A1:A3", "month");
|
||||
@@ -2549,6 +2961,56 @@ fn notes_comments_and_links_survive_a_json_save() {
|
||||
assert_eq!(after.cells, before.cells);
|
||||
}
|
||||
|
||||
/// Insert › Link sorts a target as Excel's dialog does: a sheet reference, a defined name or `#…` is
|
||||
/// a place in this document, anything else an existing file or web page. A file saved as a
|
||||
/// `location` opens nothing in Excel (#800).
|
||||
#[test]
|
||||
fn links_to_files_are_saved_as_external_relationships() {
|
||||
use std::io::Read;
|
||||
let mut s = s();
|
||||
s.execute("formulas.defineName", json!({"name": "Price", "refersTo": "=Sheet1!$A$1"})).unwrap();
|
||||
s.execute("insert.table", json!({"range": "F1:G3", "name": "Sales"})).unwrap();
|
||||
s.execute("home.insertSheet", json!({"name": "Q#1"})).unwrap();
|
||||
s.execute("sheet.activate", json!({"sheet": 0})).unwrap();
|
||||
let links = [
|
||||
("A1", "Source.xlsx", true),
|
||||
("A2", r"C:\Temp\Book.xlsx", true),
|
||||
("A3", r"..\Reports\Q1.xlsx", true),
|
||||
("A4", "Other.xlsx#Sheet1!A2", true),
|
||||
("A5", "https://example.com/a.xlsx", true),
|
||||
("A6", "Sheet1!B2", false),
|
||||
("A7", "#Sheet1!B3", false),
|
||||
("A8", "Price", false),
|
||||
("A9", "'Sheet1'!C4:D5", false),
|
||||
("A10", "Sales", false),
|
||||
("A11", "'Q#1'!B2", false),
|
||||
];
|
||||
for (cell, target, _) in links {
|
||||
s.execute("insert.link", json!({"cell": cell, "target": target})).unwrap();
|
||||
}
|
||||
let bytes = gridcraft_xlsx::write_xlsx(&s.doc().unwrap().wb).unwrap();
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes.clone())).unwrap();
|
||||
let mut part = |name: &str| {
|
||||
let mut text = String::new();
|
||||
zip.by_name(name).unwrap().read_to_string(&mut text).unwrap();
|
||||
text
|
||||
};
|
||||
let (sheet, rels) = (part("xl/worksheets/sheet1.xml"), part("xl/worksheets/_rels/sheet1.xml.rels"));
|
||||
for (cell, target, external) in links {
|
||||
let file = target.split_once('#').map_or(target, |(f, _)| f);
|
||||
let as_rel = format!("Target=\"{file}\" TargetMode=\"External\"");
|
||||
let as_location = format!("location=\"{}\"", target.trim_start_matches('#'));
|
||||
assert_eq!(rels.contains(&as_rel), external, "{cell} {target}: {rels}");
|
||||
assert_eq!(sheet.contains(&as_location), !external, "{cell} {target}: {sheet}");
|
||||
}
|
||||
assert!(sheet.contains("location=\"Sheet1!A2\""), "{sheet}");
|
||||
let (back, _) = gridcraft_xlsx::read_xlsx(&bytes).unwrap();
|
||||
for (cell, target, external) in links {
|
||||
let h = &back.sheet(0).unwrap().hyperlinks[&CellRef::parse(cell).unwrap()];
|
||||
assert_eq!((h.target.as_str(), h.external), (target.trim_start_matches('#'), external), "{cell}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_alignment_commands_and_roundtrip() {
|
||||
let mut s = s();
|
||||
@@ -2795,3 +3257,90 @@ fn a_decrypted_workbook_never_overwrites_its_encrypted_original() {
|
||||
assert_eq!(std::fs::read(&path).unwrap(), bytes, "the encrypted original is untouched");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_open_refuses_every_scheme_but_http_https_and_mailto() {
|
||||
let mut s = s();
|
||||
s.execute("insert.link", json!({"cell": "A1", "target": "file:///etc/passwd"})).unwrap();
|
||||
s.take_ui_requests();
|
||||
for url in ["file:///etc/passwd", "smb://h/s", "\\\\h\\s", "JAVASCRIPT:1"] {
|
||||
assert!(s.execute("link.open", json!({"url": url})).is_err(), "{url}");
|
||||
}
|
||||
assert!(s.execute("link.open", json!({"cell": "A1"})).is_err());
|
||||
assert!(s.take_ui_requests().is_empty());
|
||||
s.execute("link.open", json!({"url": "HTTPS://example.org/a"})).unwrap();
|
||||
assert_eq!(s.take_ui_requests(), vec![crate::UiRequest::OpenUrl("HTTPS://example.org/a".into())]);
|
||||
s.execute("link.open", json!({"url": "B2"})).unwrap();
|
||||
assert!(s.take_ui_requests().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subtotal_109_recalculates_when_row_height_or_autofit_reveals_rows() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [4], [8]]})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C1", "input": "=SUBTOTAL(109,A1:A4)"})).unwrap();
|
||||
s.execute("home.hideRows", json!({"rows": "2:3"})).unwrap();
|
||||
assert_eq!(v(&s, "C1"), Value::Number(9.0));
|
||||
// A non-zero height unhides the rows (the fuzz found the stale SUBTOTAL).
|
||||
s.execute("home.rowHeight", json!({"rows": "2:3", "height": 60.0})).unwrap();
|
||||
assert_eq!(v(&s, "C1"), Value::Number(15.0));
|
||||
s.execute("home.hideRows", json!({"rows": "2:3"})).unwrap();
|
||||
assert_eq!(v(&s, "C1"), Value::Number(9.0));
|
||||
s.execute("home.autofitRowHeight", json!({"rows": "2:3"})).unwrap();
|
||||
assert_eq!(v(&s, "C1"), Value::Number(15.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_special_validation_copies_the_rule() {
|
||||
let mut s = s();
|
||||
let ok = |s: &mut Session, cell: &str, input: &str| s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == true;
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "5"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "C3", "input": "7"})).unwrap();
|
||||
s.execute("data.validation", json!({"range": "A1", "type": "whole", "operator": "greater", "formula1": "0"})).unwrap();
|
||||
s.execute("edit.copy", json!({"range": "A1"})).unwrap();
|
||||
s.execute("edit.pasteSpecial", json!({"what": "formats", "at": "E5"})).unwrap();
|
||||
assert!(ok(&mut s, "E5", "-1"));
|
||||
s.execute("edit.pasteSpecial", json!({"what": "validation", "at": "C3"})).unwrap();
|
||||
assert!(!ok(&mut s, "C3", "-1"));
|
||||
assert!(ok(&mut s, "C3", "1"));
|
||||
assert!(!ok(&mut s, "A1", "-1"));
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "C3"})).unwrap()["text"], json!("7"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_special_validation_moves_relative_references() {
|
||||
let mut s = s();
|
||||
let ok = |s: &mut Session, cell: &str, input: &str| s.execute("data.validate", json!({"cell": cell, "input": input})).unwrap()["ok"] == true;
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "50"})).unwrap();
|
||||
s.execute("data.validation", json!({"range": "A1", "type": "custom", "formula1": "=A1>10"})).unwrap();
|
||||
s.execute("edit.copy", json!({"range": "A1"})).unwrap();
|
||||
s.execute("edit.pasteSpecial", json!({"what": "validation", "at": "C3"})).unwrap();
|
||||
assert!(!ok(&mut s, "C3", "5"), "the pasted rule checks C3, not A1");
|
||||
assert!(ok(&mut s, "C3", "20"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_as_text_keeps_identifier_columns_as_written() {
|
||||
let csv = "gtin,zip,flag,day\n8435392892278,01234,TRUE,2026-03-04\n";
|
||||
let open = |s: &mut Session, extra: serde_json::Value| {
|
||||
let mut p = json!({"name": "feed.csv", "base64": crate::io::base64_encode(csv.as_bytes())});
|
||||
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
|
||||
p.extend(extra.clone());
|
||||
}
|
||||
s.execute("file.open", p).unwrap();
|
||||
};
|
||||
let value = |s: &mut Session, cell: &str| s.execute("cell.get", json!({"cell": cell})).unwrap()["value"].clone();
|
||||
// By default fields are typed like typed input: the leading zero is lost and the GTIN is a number.
|
||||
let mut s = s();
|
||||
open(&mut s, json!({}));
|
||||
assert!(value(&mut s, "A2").is_number());
|
||||
assert_eq!(value(&mut s, "B2").as_f64(), Some(1234.0));
|
||||
// With csvAsText every field stays the text the file holds, and saves back unchanged.
|
||||
let mut s = self::s();
|
||||
open(&mut s, json!({"csvAsText": true}));
|
||||
for (cell, text) in [("A2", "8435392892278"), ("B2", "01234"), ("C2", "TRUE"), ("D2", "2026-03-04")] {
|
||||
assert_eq!(value(&mut s, cell), json!(text), "{cell}");
|
||||
}
|
||||
let saved = s.execute("file.saveBytes", json!({"format": "csv"})).unwrap();
|
||||
assert_eq!(crate::io::base64_decode(saved["base64"].as_str().unwrap()).unwrap(), csv.replace('\n', "\r\n").as_bytes());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
//! CSV and TSV export is capped: two cells far apart in a sheet would otherwise make a file as
|
||||
//! big as the whole rectangle between them. A refused export is an error and writes nothing.
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn two_far_cells() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "a"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "XFD1048576", "input": "b"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sparse_sheet_spanning_the_grid_is_refused_not_expanded() {
|
||||
let mut s = two_far_cells();
|
||||
for format in ["csv", "tsv"] {
|
||||
let err = s.execute("file.saveBytes", json!({"format": format})).unwrap_err().to_string();
|
||||
assert!(err.contains("1048576 rows by 16384 columns") && err.contains("MiB"), "{format}: {err}");
|
||||
}
|
||||
// Save As and Export write no file, and the document stays as it was.
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-csv-budget-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("big.csv");
|
||||
let err = s.execute("file.exportCsv", json!({"path": path.to_str().unwrap()})).unwrap_err().to_string();
|
||||
assert!(err.contains("over the limit"), "{err}");
|
||||
assert!(!path.exists(), "nothing is written for a refused export");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
// Other formats are unaffected, and so is a small export from the same session.
|
||||
assert!(s.execute("file.saveBytes", json!({"format": "xlsx"})).is_ok());
|
||||
s.execute("cell.set", json!({"cell": "XFD1048576", "input": ""})).unwrap();
|
||||
assert_eq!(s.execute("file.saveBytes", json!({"format": "csv"})).unwrap()["bytes"], 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_below_the_cap_keep_their_positions() {
|
||||
for (corner, bytes) in [("C3", 14), ("Z10", 272), ("XFD8", 131_082)] {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "a"})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": corner, "input": "b"})).unwrap();
|
||||
assert_eq!(s.execute("file.saveBytes", json!({"format": "csv"})).unwrap()["bytes"], bytes, "{corner}");
|
||||
}
|
||||
}
|
||||
@@ -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,28 @@
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn freeze(s: &Session) -> Option<(u32, u32)> {
|
||||
s.doc().unwrap().wb.active().unwrap().freeze
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn showing_a_custom_view_restores_its_frozen_panes() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("view.freezePanes", json!({"cell": "B2"})).unwrap();
|
||||
s.execute("view.customViews", json!({"save": "Frozen"})).unwrap();
|
||||
s.execute("view.unfreezePanes", json!({})).unwrap();
|
||||
assert_eq!(freeze(&s), None);
|
||||
s.execute("view.customViews", json!({"show": "Frozen"})).unwrap();
|
||||
assert_eq!(freeze(&s), Some((1, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn showing_an_unfrozen_custom_view_clears_current_panes() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("view.customViews", json!({"save": "Plain"})).unwrap();
|
||||
s.execute("view.freezePanes", json!({"cell": "B2"})).unwrap();
|
||||
s.execute("view.customViews", json!({"show": "Plain"})).unwrap();
|
||||
assert_eq!(freeze(&s), None);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
//! Native ink remains editable after XLSX saves, including a save after an unrelated cell edit.
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::cmd::draw::{ink_points, ink_width};
|
||||
use gridcraft_engine::core::{CellRef, Value};
|
||||
use gridcraft_engine::model::{Shape, ShapeKind};
|
||||
use serde_json::json;
|
||||
|
||||
fn workbook() -> Session {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
session
|
||||
}
|
||||
|
||||
fn reopen(session: &mut Session) -> Session {
|
||||
let saved = session.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
|
||||
let mut reopened = Session::new();
|
||||
reopened.execute("file.open", json!({"name": "ink.xlsx", "base64": saved["base64"]})).unwrap();
|
||||
reopened
|
||||
}
|
||||
|
||||
fn shapes(session: &Session) -> &[Shape] {
|
||||
&session.doc().unwrap().wb.active().unwrap().shapes
|
||||
}
|
||||
|
||||
fn assert_anchor(actual: &Shape, expected: &Shape) {
|
||||
assert_eq!(actual.anchor.cell, expected.anchor.cell);
|
||||
assert_eq!(actual.anchor.mode, expected.anchor.mode);
|
||||
for (name, actual, expected) in [
|
||||
("dx", actual.anchor.dx, expected.anchor.dx),
|
||||
("dy", actual.anchor.dy, expected.anchor.dy),
|
||||
("width", actual.anchor.width, expected.anchor.width),
|
||||
("height", actual.anchor.height, expected.anchor.height),
|
||||
] {
|
||||
assert!((actual - expected).abs() < 0.001, "anchor {name}: {actual} != {expected}");
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_ink(actual: &Shape, expected: &Shape) {
|
||||
assert_eq!(actual.kind, ShapeKind::Ink);
|
||||
let actual_points = ink_points(actual);
|
||||
let expected_points = ink_points(expected);
|
||||
assert_eq!(actual_points.len(), expected_points.len());
|
||||
for (actual, expected) in actual_points.iter().zip(&expected_points) {
|
||||
for axis in 0..2 {
|
||||
// DrawingML quantizes coordinates to integer EMUs (9,525 per model unit).
|
||||
assert!((actual[axis] - expected[axis]).abs() < 0.001, "point {actual:?} != {expected:?}");
|
||||
}
|
||||
}
|
||||
assert_eq!(actual.line, expected.line);
|
||||
assert_eq!(ink_width(actual), ink_width(expected));
|
||||
assert_anchor(actual, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ink_geometry_and_style_survive_two_saves_and_a_cell_edit() {
|
||||
let mut session = workbook();
|
||||
session
|
||||
.execute("draw.stroke", json!({"points": [[130.5, 45.25], [172.5, 86.25], [151.5, 112.25], [215.5, 59.25]], "color": "#A12BCD", "width": 3}))
|
||||
.unwrap();
|
||||
session
|
||||
.execute("draw.stroke", json!({"points": [[300, 180], [360, 180], [360, 230], [300, 230], [300, 180]], "color": "#16843B", "width": 7}))
|
||||
.unwrap();
|
||||
let expected = shapes(&session).to_vec();
|
||||
assert_eq!(expected.len(), 2);
|
||||
|
||||
for pass in 0..2 {
|
||||
session = reopen(&mut session);
|
||||
assert_eq!(shapes(&session).len(), expected.len());
|
||||
for (actual, expected) in shapes(&session).iter().zip(&expected) {
|
||||
assert_ink(actual, expected);
|
||||
}
|
||||
if pass == 0 {
|
||||
session.execute("cell.set", json!({"cell": "Z30", "input": "42"})).unwrap();
|
||||
}
|
||||
}
|
||||
assert_eq!(session.doc().unwrap().wb.active().unwrap().value(CellRef::parse("Z30").unwrap()), Value::Number(42.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reopened_ink_can_still_be_converted_to_a_shape() {
|
||||
for (points, expected_kind, expected_name) in [
|
||||
(vec![[100, 100], [200, 100], [200, 180], [100, 180], [100, 100]], ShapeKind::Rectangle, "Rectangle"),
|
||||
(vec![[100, 100], [140, 110], [220, 130]], ShapeKind::Line, "Line"),
|
||||
] {
|
||||
let mut session = workbook();
|
||||
session.execute("draw.stroke", json!({"points": points, "color": "#A12BCD", "width": 5})).unwrap();
|
||||
let original = shapes(&session)[0].clone();
|
||||
session = reopen(&mut session);
|
||||
let id = shapes(&session)[0].id;
|
||||
let converted = session.execute("draw.inkToShape", json!({"id": id})).unwrap();
|
||||
assert_eq!(converted["kind"], expected_name);
|
||||
let shape = &shapes(&session)[0];
|
||||
assert_eq!(shape.kind, expected_kind);
|
||||
assert_eq!(shape.line, original.line);
|
||||
assert!(shape.text.is_empty());
|
||||
assert_anchor(shape, &original);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_shape_text_is_not_mistaken_for_ink_points() {
|
||||
let mut session = workbook();
|
||||
session
|
||||
.execute(
|
||||
"insert.shape",
|
||||
json!({"kind": "rectangle", "at": "D6", "width": 120, "height": 60, "fill": "#112233", "line": "#A12BCD", "text": "0.0,0.0;50.0,20.0"}),
|
||||
)
|
||||
.unwrap();
|
||||
let original = shapes(&session)[0].clone();
|
||||
for _ in 0..2 {
|
||||
session = reopen(&mut session);
|
||||
assert_eq!(shapes(&session).len(), 1);
|
||||
let shape = &shapes(&session)[0];
|
||||
assert_eq!(shape.kind, ShapeKind::Rectangle);
|
||||
assert_eq!(shape.text, original.text);
|
||||
assert_eq!(shape.fill, original.fill);
|
||||
assert_eq!(shape.line, original.line);
|
||||
assert_anchor(shape, &original);
|
||||
assert!(session.execute("draw.inkToShape", json!({})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_vertical_and_signed_highlighter_strokes_survive_reopening() {
|
||||
for points in [vec![[25.1, 50.2], [140.3, 50.2]], vec![[40.2, 10.1], [40.2, 100.3]], vec![[-12.3, -4.2], [20.1, 10.2], [80.3, -1.4]]] {
|
||||
let mut session = workbook();
|
||||
session.execute("draw.stroke", json!({"points": points, "color": "#16843B", "width": 12})).unwrap();
|
||||
let expected = shapes(&session)[0].clone();
|
||||
for _ in 0..2 {
|
||||
session = reopen(&mut session);
|
||||
assert_ink(&shapes(&session)[0], &expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
use gridcraft_engine::{EngineError, Session};
|
||||
use serde_json::json;
|
||||
|
||||
fn new_session() -> Session {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
session
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_with_an_out_of_range_count_is_a_normal_error() {
|
||||
for (id, key, at) in [("home.insertRows", "rows", "3:3"), ("home.insertColumns", "cols", "C:C")] {
|
||||
let mut session = new_session();
|
||||
let err = session.execute(id, json!({ key: at, "count": u32::MAX })).unwrap_err();
|
||||
assert!(matches!(err, EngineError::BadParams { .. }), "{id}: {err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_after_a_formatted_line_with_an_out_of_range_count_is_a_normal_error() {
|
||||
// A custom size on the preceding line and no cells: the used-range guard has nothing to check.
|
||||
let mut session = new_session();
|
||||
session.execute("home.rowHeight", json!({"rows": "2:2", "height": 30})).unwrap();
|
||||
let err = session.execute("home.insertRows", json!({"rows": "3:3", "count": u32::MAX})).unwrap_err();
|
||||
assert!(matches!(err, EngineError::BadParams { .. }), "{err:?}");
|
||||
|
||||
let mut session = new_session();
|
||||
session.execute("home.columnWidth", json!({"cols": "B:B", "width": 100})).unwrap();
|
||||
let err = session.execute("home.insertColumns", json!({"cols": "C:C", "count": u32::MAX})).unwrap_err();
|
||||
assert!(matches!(err, EngineError::BadParams { .. }), "{err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_with_an_out_of_range_count_does_not_panic() {
|
||||
for (id, key, at) in [("home.deleteRows", "rows", "3:3"), ("home.deleteColumns", "cols", "C:C")] {
|
||||
let mut session = new_session();
|
||||
let res = session.execute(id, json!({ key: at, "count": u32::MAX }));
|
||||
assert!(!matches!(res, Err(EngineError::Internal(..))), "{id}: {res:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_delete_with_a_small_count_still_work() {
|
||||
let mut session = new_session();
|
||||
session.execute("home.insertRows", json!({"rows": "1:1", "count": 3})).unwrap();
|
||||
session.execute("home.deleteRows", json!({"rows": "1:1", "count": 3})).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
use gridcraft_core::{CellError, CellRef, Value};
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn cell_set_offset_extreme_arguments_return_ref() {
|
||||
let mut session = Session::new();
|
||||
session.new_workbook();
|
||||
for formula in [
|
||||
"=OFFSET(B2,1E300,0)",
|
||||
"=OFFSET(B2,0,1E300)",
|
||||
"=OFFSET(B2,0,0,1E300,1)",
|
||||
"=OFFSET(B2,0,0,1,1E300)",
|
||||
"=OFFSET(B2,-1E300,0)",
|
||||
"=OFFSET(B2,0,-1E300)",
|
||||
"=OFFSET(B2,0,0,-1E300,1)",
|
||||
"=OFFSET(B2,0,0,1,-1E300)",
|
||||
"=OFFSET(B2,1E300,0,-1E300,1)",
|
||||
"=OFFSET(B2,0,1E300,1,-1E300)",
|
||||
] {
|
||||
session.execute("cell.set", json!({"cell": "D1", "input": formula})).unwrap();
|
||||
assert_eq!(session.doc().unwrap().wb.sheet(0).unwrap().value(CellRef::new(0, 3)), Value::Error(CellError::Ref), "{formula}");
|
||||
}
|
||||
}
|
||||
@@ -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,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,61 @@
|
||||
//! Whole-row and whole-column formatting must re-fit row heights (issue #833).
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
fn sheet_mut(s: &mut gridcraft_engine::Session) -> &mut gridcraft_engine::model::Sheet {
|
||||
std::sync::Arc::make_mut(&mut s.active_mut().unwrap().wb).sheet_mut(0).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_row_font_format_grows_row_height_and_undoes() {
|
||||
let mut s = gridcraft_engine::Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A5", "values": [["plain big bold"]]})).unwrap();
|
||||
let before = s.doc().unwrap().wb.active().unwrap().row_height(4);
|
||||
s.execute("home.fontSize", json!({"range": "5:5", "size": 20})).unwrap();
|
||||
let after = s.doc().unwrap().wb.active().unwrap().row_height(4);
|
||||
assert!(after > before + 10.0, "20pt whole-row format should grow the row: before={before}, after={after}");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().row_height(4), before, "undo must restore the height");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_column_font_format_grows_only_rows_with_cells() {
|
||||
let mut s = gridcraft_engine::Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["a"]]})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "A50", "values": [["b"]]})).unwrap();
|
||||
s.execute("home.fontSize", json!({"range": "A:A", "size": 20})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert!(sh.row_height(0) > 30.0 && sh.row_height(49) > 30.0, "rows with cells grow");
|
||||
assert!(sh.rows.len() <= 3, "full-column format must fit only rows that have cells, rows map has {} entries", sh.rows.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_row_format_does_not_resize_custom_height_rows() {
|
||||
let mut s = gridcraft_engine::Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A5", "values": [["plain big bold"]]})).unwrap();
|
||||
{
|
||||
let sh = sheet_mut(&mut s);
|
||||
let e = sh.rows.entry(4).or_default();
|
||||
e.custom = true;
|
||||
e.size = Some(25.0);
|
||||
}
|
||||
s.execute("home.fontSize", json!({"range": "5:5", "size": 20})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().row_height(4), 25.0, "custom-height rows keep their height");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_row_format_keeps_hidden_rows_hidden() {
|
||||
let mut s = gridcraft_engine::Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A5", "values": [["plain big bold"]]})).unwrap();
|
||||
let before = s.doc().unwrap().wb.active().unwrap().row_height(4);
|
||||
sheet_mut(&mut s).rows.entry(4).or_default().hidden = true;
|
||||
s.execute("home.fontSize", json!({"range": "5:5", "size": 20})).unwrap();
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
let info = sh.rows.get(&4).unwrap();
|
||||
assert!(info.size.unwrap_or(before) > before + 10.0, "hidden row still grows to its font: before={before}, size={:?}", info.size);
|
||||
assert!(info.hidden, "formatting a hidden row must not unhide it");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
const NOT_OBJECTS: [&str; 4] = ["\"[1,2]\"", "\"7\"", "\"true\"", "\"\"\"hi\"\"\""];
|
||||
|
||||
fn with_name(name: &str, refers_to: &str) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("formulas.defineName", json!({"name": name, "refersTo": refers_to})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_a_view_works_when_stored_list_is_not_an_object() {
|
||||
for text in NOT_OBJECTS {
|
||||
let mut s = with_name("_sc_custom_views", text);
|
||||
let out = s.execute("view.customViews", json!({"save": "v"})).unwrap();
|
||||
assert!(out.get("v").is_some(), "{text}: {out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_a_view_keeps_the_keys_of_a_stored_object() {
|
||||
let mut s = with_name("_sc_custom_views", "\"{\"\"a\"\":1}\"");
|
||||
let out = s.execute("view.customViews", json!({"save": "v"})).unwrap();
|
||||
assert!(out.get("a").is_some() && out.get("v").is_some(), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creating_a_script_works_when_stored_list_is_not_an_object() {
|
||||
for text in NOT_OBJECTS {
|
||||
let mut s = with_name("_sc_scripts", text);
|
||||
s.execute("automate.newScript", json!({"name": "s1", "commands": []})).unwrap();
|
||||
let list = s.execute("automate.allScripts", json!({})).unwrap();
|
||||
assert!(list.to_string().contains("s1"), "{text}: {list}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopping_a_recording_works_when_stored_list_is_not_an_object() {
|
||||
for text in NOT_OBJECTS {
|
||||
let mut s = with_name("_sc_scripts", text);
|
||||
s.execute("automate.recordActions", json!({})).unwrap();
|
||||
s.execute("automate.recordActions", json!({"stop": true, "name": "s1"})).unwrap();
|
||||
let list = s.execute("automate.allScripts", json!({})).unwrap();
|
||||
assert!(list.to_string().contains("s1"), "{text}: {list}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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"]]})).unwrap();
|
||||
s.execute("insert.table", json!({"range":"A1:B2", "header":true, "name":"T"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn table_range(s: &Session) -> String {
|
||||
s.doc().unwrap().wb.active().unwrap().tables[0].range.a1()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_to_disjoint_range_is_rejected_and_keeps_table() {
|
||||
let mut s = table();
|
||||
assert!(s.execute("table.resize", json!({"table":"T", "range":"D1:E2"})).is_err());
|
||||
assert_eq!(table_range(&s), "A1:B2");
|
||||
assert_eq!(s.doc().unwrap().wb.active().unwrap().tables[0].columns[1].name, "Qty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_to_overlapping_range_still_works() {
|
||||
let mut s = table();
|
||||
s.execute("table.resize", json!({"table":"T", "range":"B1:C2"})).unwrap();
|
||||
assert_eq!(table_range(&s), "B1:C2");
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
use gridcraft_core::{CellRef, MAX_ROWS};
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn refused(range: &str) {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("insert.table", json!({"range": range, "header": true})).unwrap();
|
||||
let before = s.doc().unwrap().wb.active().unwrap().tables[0].clone();
|
||||
assert!(s.execute("table.totalRow", json!({"on": true, "table": &before.name})).is_err(), "{range}");
|
||||
let sh = s.doc().unwrap().wb.active().unwrap();
|
||||
assert_eq!(sh.tables[0].range, before.range, "{range}");
|
||||
assert_eq!(sh.tables[0].range.end.row, MAX_ROWS - 1, "{range}");
|
||||
assert!(!sh.tables[0].totals_row, "{range}");
|
||||
assert!(sh.value(CellRef::new(MAX_ROWS, 0)).is_empty(), "{range}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_row_is_refused_on_a_table_ending_at_the_last_worksheet_row() {
|
||||
refused("A1048575:B1048576");
|
||||
refused("A1048576:B1048576");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_row_still_grows_an_ordinary_table() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Item", "Amount"], ["a", 2], ["b", 3]]})).unwrap();
|
||||
s.execute("insert.table", json!({"range": "A1:B3", "header": true})).unwrap();
|
||||
s.execute("table.totalRow", json!({"on": true, "table": "Table1"})).unwrap();
|
||||
let t = &s.doc().unwrap().wb.active().unwrap().tables[0];
|
||||
assert!(t.totals_row);
|
||||
assert_eq!(t.range.end.row, 3);
|
||||
}
|
||||
@@ -264,11 +264,10 @@ pub fn rename_sheet(e: Expr, old: &str, new: &str) -> Expr {
|
||||
}
|
||||
Expr::Ref(r)
|
||||
}
|
||||
// Sheet-qualified defined name `Old!MyName` is stored as one string.
|
||||
// Sheet-qualified defined name `Old!MyName` is stored as one string, with the bare sheet name as the parser
|
||||
// stores it (the printer adds the quotes).
|
||||
Expr::Name(n) => match n.split_at_checked(old.len()) {
|
||||
Some((head, rest)) if head.eq_ignore_ascii_case(old) && rest.starts_with('!') => {
|
||||
Expr::Name(format!("{}{rest}", crate::printer::quote_sheet(new)))
|
||||
}
|
||||
Some((head, rest)) if head.eq_ignore_ascii_case(old) && rest.starts_with('!') => Expr::Name(format!("{new}{rest}")),
|
||||
_ => Expr::Name(n),
|
||||
},
|
||||
other => other,
|
||||
|
||||
@@ -12,7 +12,7 @@ pub mod printer;
|
||||
|
||||
pub use ast::{Anchor, BinOp, Expr, RefKind, Reference, SheetSel, StructItem, StructRef, TrimMode, UnOp};
|
||||
pub use input::{FormulaLocale, parse_input, print_input};
|
||||
pub use parser::{ParseError, normalize_function_name, parse};
|
||||
pub use parser::{MAX_FORMULA_LEN, ParseError, normalize_function_name, parse};
|
||||
pub use printer::{print, print_r1c1, quote_sheet};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -17,6 +17,11 @@ pub struct ParseError {
|
||||
|
||||
pub(crate) const MAX_DEPTH: usize = 256;
|
||||
|
||||
/// Longest formula accepted, in characters: Excel's limit for formula contents. It also bounds
|
||||
/// the depth of the trees long operator chains build (`1+1+…`), which dropping and evaluating
|
||||
/// walk recursively; without it a formula of a few hundred kilobytes in a file overflows the stack.
|
||||
pub const MAX_FORMULA_LEN: usize = 8_192;
|
||||
|
||||
/// Parses formula text. A leading `=` (or `+`/`-` as Lotus users type) is accepted and skipped
|
||||
/// for `=`.
|
||||
pub fn parse(src: &str) -> Result<Expr, ParseError> {
|
||||
@@ -25,6 +30,9 @@ pub fn parse(src: &str) -> Result<Expr, ParseError> {
|
||||
|
||||
pub(crate) fn parse_syntax(src: &str, locale: crate::FormulaLocale) -> Result<Expr, ParseError> {
|
||||
let body = src.strip_prefix('=').unwrap_or(src);
|
||||
if body.len() > MAX_FORMULA_LEN && body.chars().count() > MAX_FORMULA_LEN {
|
||||
return Err(ParseError { msg: format!("formula is longer than {MAX_FORMULA_LEN} characters"), pos: 0 });
|
||||
}
|
||||
let toks = tokenize_locale(body, locale).map_err(|e| ParseError { msg: e.msg, pos: e.pos })?;
|
||||
let toks = significant_spaces(toks);
|
||||
let mut p = Parser { toks, i: 0, depth: 0, input: locale == crate::FormulaLocale::Es };
|
||||
@@ -50,8 +58,9 @@ fn significant_spaces(toks: Vec<Token>) -> Vec<Token> {
|
||||
let mut out: Vec<Token> = Vec::with_capacity(toks.len());
|
||||
for (i, t) in toks.iter().enumerate() {
|
||||
if t.tok == Tok::Space {
|
||||
let prev_end = out.last().is_some_and(|p| matches!(p.tok, Tok::Word(_) | Tok::RParen | Tok::Struct(_)));
|
||||
let next_start = toks.get(i + 1).is_some_and(|n| matches!(n.tok, Tok::Word(_) | Tok::Sheet(..) | Tok::LParen | Tok::Func(_)));
|
||||
let prev_end = out.last().is_some_and(|p| matches!(p.tok, Tok::Word(_) | Tok::RParen | Tok::Struct(_) | Tok::Error(_)));
|
||||
let next_start =
|
||||
toks.get(i + 1).is_some_and(|n| matches!(n.tok, Tok::Word(_) | Tok::Sheet(..) | Tok::LParen | Tok::Func(_) | Tok::Error(_)));
|
||||
if prev_end && next_start {
|
||||
out.push(t.clone());
|
||||
}
|
||||
@@ -88,15 +97,19 @@ fn word_is_cell(w: &str) -> Option<Anchor> {
|
||||
Some(Anchor { row, col, row_abs, col_abs })
|
||||
}
|
||||
|
||||
/// `_xlfn.XLOOKUP` → `XLOOKUP`.
|
||||
/// `_xlfn.XLOOKUP` → `XLOOKUP`. `_xludf.` is kept (`_xludf.SUM`): it marks a user-defined
|
||||
/// function, never the built-in of the same name, and files need it back.
|
||||
pub fn normalize_function_name(name: &str) -> String {
|
||||
let u = name.to_ascii_uppercase();
|
||||
for p in ["_XLFN._XLWS.", "_XLFN.", "_XLWS.", "_XLUDF."] {
|
||||
for p in ["_XLFN._XLWS.", "_XLFN.", "_XLWS."] {
|
||||
if let Some(r) = u.strip_prefix(p) {
|
||||
return r.to_string();
|
||||
}
|
||||
}
|
||||
u
|
||||
match u.strip_prefix("_XLUDF.") {
|
||||
Some(r) => format!("_xludf.{r}"),
|
||||
None => u,
|
||||
}
|
||||
}
|
||||
|
||||
impl Parser {
|
||||
@@ -155,11 +168,18 @@ impl Parser {
|
||||
return Err(self.err("formula is nested too deeply"));
|
||||
}
|
||||
let mut lhs = self.prefix(allow_union)?;
|
||||
// Postfix operators (`%`, invoking a result) wrap `lhs` without recursing: they count
|
||||
// towards the nesting limit, so `1%%%…` can't build a tree deeper than it.
|
||||
let mut postfix = 0;
|
||||
loop {
|
||||
if *self.peek() == Tok::Op("%") {
|
||||
if 70 < min_bp {
|
||||
break;
|
||||
}
|
||||
postfix += 1;
|
||||
if self.depth + postfix > MAX_DEPTH {
|
||||
return Err(self.err("formula is nested too deeply"));
|
||||
}
|
||||
self.next();
|
||||
lhs = Expr::Unary(UnOp::Percent, Box::new(lhs));
|
||||
continue;
|
||||
@@ -169,6 +189,10 @@ impl Parser {
|
||||
if let Expr::Name(_) = lhs {
|
||||
// A name followed by '(' was lexed as Func; this is `(name)(…)` only.
|
||||
}
|
||||
postfix += 1;
|
||||
if self.depth + postfix > MAX_DEPTH {
|
||||
return Err(self.err("formula is nested too deeply"));
|
||||
}
|
||||
self.next();
|
||||
let args = self.args()?;
|
||||
lhs = Expr::Invoke(Box::new(lhs), args);
|
||||
@@ -357,6 +381,10 @@ impl Parser {
|
||||
if w.chars().next().is_some_and(|c| c.is_ascii_digit()) {
|
||||
return Err(self.err(format!("unexpected `{w}`")));
|
||||
}
|
||||
// A function passed by name is saved as `_xleta.SUM`.
|
||||
if let Some(f) = w.get(..7).filter(|p| p.eq_ignore_ascii_case("_xleta.")).and_then(|_| w.get(7..)) {
|
||||
return Ok(Expr::Name(f.to_ascii_uppercase()));
|
||||
}
|
||||
Ok(Expr::Name(w.to_string()))
|
||||
}
|
||||
|
||||
@@ -417,6 +445,9 @@ fn combine(op: BinOp, lhs: Expr, rhs: Expr) -> Expr {
|
||||
pub fn parse_struct(table: &str, body: &str) -> Result<StructRef, String> {
|
||||
let mut specifiers = Vec::new();
|
||||
let mut cols: Vec<String> = Vec::new();
|
||||
// Spaces at the edges of a column name are part of it (`Table1[Qty ]` names `Qty `), so the
|
||||
// untrimmed body is what a lone column is read from.
|
||||
let raw = body;
|
||||
let body = body.trim();
|
||||
let unescape = |s: &str| {
|
||||
let mut out = String::new();
|
||||
@@ -468,7 +499,7 @@ pub fn parse_struct(table: &str, body: &str) -> Result<StructRef, String> {
|
||||
} else if let Some(i) = item(body) {
|
||||
specifiers.push(i);
|
||||
} else if !body.is_empty() {
|
||||
cols.push(column(body)?);
|
||||
cols.push(column(raw)?);
|
||||
}
|
||||
} else {
|
||||
// [[#Headers],[Col1]:[Col3]], [[#This Row],[Col 1]] or [[#This Row],Col1]
|
||||
|
||||
@@ -91,6 +91,16 @@ fn range_r1c1(r: &Reference, at: CellRef, sep: &str) -> String {
|
||||
}
|
||||
|
||||
pub fn struct_ref(s: &StructRef) -> String {
|
||||
write_struct_ref(s, true)
|
||||
}
|
||||
|
||||
/// Structured reference as files spell it: `[@Col]` is only Excel's display shorthand, files
|
||||
/// hold `[[#This Row],[Col]]` (and `[#This Row]` for `[@]`).
|
||||
pub fn struct_ref_file(s: &StructRef) -> String {
|
||||
write_struct_ref(s, false)
|
||||
}
|
||||
|
||||
fn write_struct_ref(s: &StructRef, at_shorthand: bool) -> String {
|
||||
let esc = |c: &str| c.replace('\'', "''").replace('[', "'[").replace(']', "']").replace('#', "'#");
|
||||
let item = |i: &StructItem| match i {
|
||||
StructItem::All => "#All",
|
||||
@@ -99,7 +109,7 @@ pub fn struct_ref(s: &StructRef) -> String {
|
||||
StructItem::Totals => "#Totals",
|
||||
StructItem::ThisRow => "#This Row",
|
||||
};
|
||||
let this_row = s.specifiers == [StructItem::ThisRow];
|
||||
let this_row = at_shorthand && s.specifiers == [StructItem::ThisRow];
|
||||
let mut out = s.table.clone();
|
||||
let simple_col = |c: &str| !c.chars().any(|ch| matches!(ch, ' ' | '[' | ']' | '#' | '\'' | ',' | ':'));
|
||||
match (&s.col_start, &s.col_end) {
|
||||
@@ -109,7 +119,12 @@ pub fn struct_ref(s: &StructRef) -> String {
|
||||
let _ = write!(out, "[{}]", s.specifiers.first().map(item).unwrap_or(""));
|
||||
}
|
||||
(Some(c), None) if s.specifiers.is_empty() => {
|
||||
let _ = write!(out, "[{}]", esc(c));
|
||||
// Excel writes a name with spaces at its edges in the double-bracket form.
|
||||
if c.trim() == c {
|
||||
let _ = write!(out, "[{}]", esc(c));
|
||||
} else {
|
||||
let _ = write!(out, "[[{}]]", esc(c));
|
||||
}
|
||||
}
|
||||
(Some(c), None) if this_row => {
|
||||
if simple_col(c) {
|
||||
@@ -178,7 +193,15 @@ fn write_expr(s: &mut String, e: &Expr, r1c1: Option<CellRef>) {
|
||||
Some(at) => s.push_str(&reference_r1c1(r, at)),
|
||||
None => s.push_str(&reference_a1(r)),
|
||||
},
|
||||
Expr::Name(n) => s.push_str(n),
|
||||
Expr::Name(n) => match n.rsplit_once('!') {
|
||||
// A sheet-qualified name is stored with the bare sheet name (`My Sheet!Total`): quote it so it parses back.
|
||||
Some((sheet, name)) => {
|
||||
s.push_str("e_sheet(sheet));
|
||||
s.push('!');
|
||||
s.push_str(name);
|
||||
}
|
||||
_ => s.push_str(n),
|
||||
},
|
||||
Expr::Struct(st) => s.push_str(&struct_ref(st)),
|
||||
Expr::Unary(op, x) => match op {
|
||||
UnOp::Neg => {
|
||||
@@ -224,10 +247,20 @@ fn write_expr(s: &mut String, e: &Expr, r1c1: Option<CellRef>) {
|
||||
}
|
||||
},
|
||||
},
|
||||
Expr::Binary(op, a, b) => {
|
||||
write_expr(s, a, r1c1);
|
||||
s.push_str(op.symbol());
|
||||
write_expr(s, b, r1c1);
|
||||
Expr::Binary(..) => {
|
||||
// Walk the left spine iteratively: a flat chain such as `1+1+1+...` parses into a
|
||||
// tree one level deeper per operator, and recursing on it could exhaust the stack.
|
||||
let mut rest: Vec<(&BinOp, &Expr)> = Vec::new();
|
||||
let mut first = e;
|
||||
while let Expr::Binary(op, a, b) = first {
|
||||
rest.push((op, b));
|
||||
first = a;
|
||||
}
|
||||
write_expr(s, first, r1c1);
|
||||
for (op, right) in rest.into_iter().rev() {
|
||||
s.push_str(op.symbol());
|
||||
write_expr(s, right, r1c1);
|
||||
}
|
||||
}
|
||||
Expr::Call(name, args) => {
|
||||
s.push_str(name);
|
||||
|
||||
@@ -5,6 +5,29 @@ fn rt(src: &str) -> String {
|
||||
print(&parse(src).unwrap_or_else(|e| panic!("{src}: {e}")))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quoted_sheet_qualified_name_keeps_quotes() {
|
||||
assert_eq!(rt("'My Sheet'!Total"), "'My Sheet'!Total");
|
||||
assert_eq!(rt("'Data'!Total"), "Data!Total");
|
||||
assert_eq!(rt("'It''s'!Total+1"), "'It''s'!Total+1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intersection_with_deleted_operand_reparses() {
|
||||
// Deleting the columns of an intersection operand prints `A1:B2 #REF!`; that text must parse back.
|
||||
assert_eq!(rt("A1:B2 #REF!"), "A1:B2 #REF!");
|
||||
assert_eq!(rt("#REF! A1:B2"), "#REF! A1:B2");
|
||||
assert_eq!(parse("A1:B2 #REF!"), parse("A1:B2 (#REF!)").map(|e| strip_paren(&e)));
|
||||
}
|
||||
|
||||
fn strip_paren(e: &Expr) -> Expr {
|
||||
match e {
|
||||
Expr::Binary(op, a, b) => Expr::Binary(*op, Box::new(strip_paren(a)), Box::new(strip_paren(b))),
|
||||
Expr::Paren(x) => strip_paren(x),
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrips() {
|
||||
for f in [
|
||||
@@ -48,6 +71,8 @@ fn normalises() {
|
||||
assert_eq!(rt("=sum(a1:b2)"), "SUM(A1:B2)");
|
||||
assert_eq!(rt("= 1 + 2"), "1+2");
|
||||
assert_eq!(rt("_xlfn.XLOOKUP(1,A:A,B:B)"), "XLOOKUP(1,A:A,B:B)");
|
||||
assert_eq!(rt("_xlfn.GROUPBY(A1:A3,B1:B3,_xleta.SUM)"), "GROUPBY(A1:A3,B1:B3,SUM)");
|
||||
assert_eq!(rt("_XLUDF.concat(\"a\",\"b\")"), "_xludf.CONCAT(\"a\",\"b\")");
|
||||
assert_eq!(rt("true"), "TRUE");
|
||||
assert_eq!(rt("'Sheet1'!A1"), "Sheet1!A1");
|
||||
}
|
||||
@@ -183,6 +208,10 @@ fn rename_sheet_updates_qualified_names() {
|
||||
assert_eq!(print(&rename_sheet(e, "data", "Numbers")), "Numbers!MyName+MyName+Other!MyName");
|
||||
let e = parse("Data!MyName").unwrap();
|
||||
assert_eq!(print(&rename_sheet(e, "Data", "New Name")), "'New Name'!MyName");
|
||||
// Stored bare, like a parsed name, so the evaluator finds the sheet and a second rename still matches.
|
||||
let e = rename_sheet(parse("Data!MyName").unwrap(), "Data", "It's");
|
||||
assert_eq!(e, Expr::Name("It's!MyName".into()));
|
||||
assert_eq!(print(&rename_sheet(e, "It's", "Totals")), "Totals!MyName");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -248,6 +277,20 @@ fn unbracketed_this_row_column() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn struct_column_keeps_edge_spaces() {
|
||||
// The spaces are part of the column name: `Table1[Qty ]` names `Qty `, never `Qty`.
|
||||
let Expr::Struct(s) = parse("Table1[Qty ]").unwrap() else { panic!() };
|
||||
assert_eq!(s.col_start.as_deref(), Some("Qty "));
|
||||
let Expr::Struct(s) = parse("Table1[ Qty]").unwrap() else { panic!() };
|
||||
assert_eq!(s.col_start.as_deref(), Some(" Qty"));
|
||||
// Excel writes such a column in the double-bracket form.
|
||||
assert_eq!(rt("Table1[Qty ]"), "Table1[[Qty ]]");
|
||||
for f in ["Table1[[Qty ]]", "Table1[[ Sum for Q2 ]]", "Table1[[#Totals],[Qty ]]", "Table1[@[Qty ]]", "Table1[Unit Price]"] {
|
||||
assert_eq!(rt(f), f);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_references() {
|
||||
for f in [
|
||||
@@ -301,3 +344,60 @@ fn trim_references_adjust() {
|
||||
let refs: Vec<String> = parse("SUM(Sheet2!A1:.B5)").unwrap().references().iter().map(|r| printer::reference_a1(r)).collect();
|
||||
assert_eq!(refs, ["Sheet2!A1:B5"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prints_long_flat_chain_without_deep_recursion() {
|
||||
// Small stack: a recursive printer overflows long before 20000 levels.
|
||||
let handle = std::thread::Builder::new()
|
||||
.stack_size(256 * 1024)
|
||||
.spawn(|| {
|
||||
let mut e = Expr::Number(1.0);
|
||||
for _ in 0..20000 {
|
||||
e = Expr::Binary(BinOp::Add, Box::new(e), Box::new(Expr::Number(1.0)));
|
||||
}
|
||||
let text = print(&e);
|
||||
let r1c1 = print_r1c1(&e, gridcraft_core::CellRef { row: 0, col: 0 });
|
||||
// Dropping the deep tree recurses; leak it so only the printer is under test.
|
||||
std::mem::forget(e);
|
||||
(text, r1c1)
|
||||
})
|
||||
.expect("spawn");
|
||||
let (text, r1c1) = handle.join().expect("printer must not overflow the stack");
|
||||
assert_eq!(text, vec!["1"; 20001].join("+"));
|
||||
assert_eq!(r1c1, text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_formulas_are_errors_not_stack_overflows() {
|
||||
// A small stack: parsing must fail cleanly instead of building trees deeper than the nesting
|
||||
// limit, which dropping, printing and evaluating walk recursively.
|
||||
let handle = std::thread::Builder::new()
|
||||
.stack_size(512 * 1024)
|
||||
.spawn(|| {
|
||||
let percents = format!("1{}", "%".repeat(1_000));
|
||||
let invokes = format!("LAMBDA(x,x)(1){}", "(1)".repeat(1_000));
|
||||
let long_chain = format!("1{}", "+1".repeat(100_000));
|
||||
let huge_percents = format!("1{}", "%".repeat(1_000_000));
|
||||
(parse(&percents).is_err(), parse(&invokes).is_err(), parse(&long_chain).is_err(), parse(&huge_percents).is_err())
|
||||
})
|
||||
.expect("spawn");
|
||||
assert_eq!(handle.join().expect("parsing must not overflow the stack"), (true, true, true, true));
|
||||
// Within the limits these still parse.
|
||||
assert!(parse(&format!("1{}", "%".repeat(100))).is_ok());
|
||||
assert!(parse("LAMBDA(x,x)(1)(2)").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formulas_longer_than_excel_allows_are_refused() {
|
||||
let at_limit = format!("1{}", "+1".repeat((crate::MAX_FORMULA_LEN - 1) / 2));
|
||||
assert_eq!(at_limit.chars().count(), crate::MAX_FORMULA_LEN - 1);
|
||||
assert!(parse(&at_limit).is_ok());
|
||||
assert!(parse(&format!("={at_limit}")).is_ok(), "the leading = does not count");
|
||||
let over = format!("{at_limit}+1");
|
||||
let err = parse(&over).unwrap_err();
|
||||
assert!(err.msg.contains("8192"), "{}", err.msg);
|
||||
// Characters, not bytes: 8,000 two-byte characters in a text literal are within the limit.
|
||||
let text = format!("\"{}\"", "é".repeat(8_000));
|
||||
assert!(text.len() > crate::MAX_FORMULA_LEN);
|
||||
assert!(parse(&text).is_ok());
|
||||
}
|
||||
|
||||
@@ -5,6 +5,10 @@ use std::cmp::Ordering;
|
||||
use gridcraft_core::parse::parse_number_text;
|
||||
use gridcraft_core::{CellError, Value, compare_numbers, compare_text};
|
||||
|
||||
/// Longest text criterion COUNTIF and its relatives accept, in UTF-16 code units as Excel
|
||||
/// counts them (😀 is two units); a longer one is `#VALUE!`.
|
||||
const MAX_CRITERION_UTF16_UNITS: usize = 255;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Op {
|
||||
Eq,
|
||||
@@ -22,6 +26,15 @@ enum Operand {
|
||||
Error(CellError),
|
||||
/// Text (lower-cased chars kept for wildcard matching).
|
||||
Text(String),
|
||||
/// A criterion cut to its first 255 UTF-16 units in the middle of a surrogate pair: the
|
||||
/// complete characters before the cut, followed (in Excel) by a lone high surrogate.
|
||||
///
|
||||
/// No cell text holds a lone surrogate, so `=` matches nothing and `<>` everything, as
|
||||
/// Excel gives 0 for SUMIFS with a criterion of 128 emoji. For `<`, `<=`, `>` and `>=` the
|
||||
/// criterion sorts just above the kept text: a cell matches `>`/`>=` when it sorts above
|
||||
/// that text, and `<`/`<=` otherwise. This is an approximation (Excel wasn't checked) and
|
||||
/// only matters for comparison criteria over 255 units.
|
||||
CutText(String),
|
||||
/// Nothing after the operator (`"="`, `"<>"`) or an empty criterion.
|
||||
Blank,
|
||||
}
|
||||
@@ -48,25 +61,52 @@ impl Criterion {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_text(t: &str) -> Criterion {
|
||||
let (op, rest) = if let Some(r) = t.strip_prefix(">=") {
|
||||
(Op::Ge, r)
|
||||
} else if let Some(r) = t.strip_prefix("<=") {
|
||||
(Op::Le, r)
|
||||
} else if let Some(r) = t.strip_prefix("<>") {
|
||||
(Op::Ne, r)
|
||||
} else if let Some(r) = t.strip_prefix('=') {
|
||||
(Op::Eq, r)
|
||||
} else if let Some(r) = t.strip_prefix('>') {
|
||||
(Op::Gt, r)
|
||||
} else if let Some(r) = t.strip_prefix('<') {
|
||||
(Op::Lt, r)
|
||||
} else {
|
||||
if t.is_empty() {
|
||||
return Criterion { op: Op::Eq, operand: Operand::Blank, bare_empty: true };
|
||||
/// Parses the criteria argument of COUNTIF, SUMIF, AVERAGEIF and the single-pair *IFS
|
||||
/// functions: like `parse`, but text longer than 255 UTF-16 code units is `#VALUE!`, as in
|
||||
/// Excel (checked on Excel 2016 and Microsoft 365: `"a"×254 & "😀"` is 255 characters but
|
||||
/// 256 units, and is `#VALUE!`).
|
||||
pub(crate) fn parse_arg(v: &Value) -> Result<Criterion, CellError> {
|
||||
match v {
|
||||
Value::Array(a) => Criterion::parse_arg(a.data.first().unwrap_or(&Value::Empty)),
|
||||
// A UTF-8 string has at least as many bytes as UTF-16 units, so short text skips the count.
|
||||
Value::Text(t) if t.len() > MAX_CRITERION_UTF16_UNITS && t.encode_utf16().nth(MAX_CRITERION_UTF16_UNITS).is_some() => {
|
||||
Err(CellError::Value)
|
||||
}
|
||||
(Op::Eq, t)
|
||||
};
|
||||
other => Ok(Criterion::parse(other)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a criteria argument of an *IFS function with two or more criteria pairs. Excel
|
||||
/// doesn't reject a longer text criterion there: it matches its first 255 UTF-16 code units
|
||||
/// (checked on Excel 2016 and Microsoft 365 with COUNTIFS, SUMIFS, AVERAGEIFS, MAXIFS and
|
||||
/// MINIFS). A cut through a surrogate pair matches nothing; see `Operand::CutText`.
|
||||
pub(crate) fn parse_arg_truncated(v: &Value) -> Criterion {
|
||||
match v {
|
||||
Value::Array(a) => Criterion::parse_arg_truncated(a.data.first().unwrap_or(&Value::Empty)),
|
||||
Value::Text(t) if t.len() > MAX_CRITERION_UTF16_UNITS => {
|
||||
let mut units = 0;
|
||||
for (i, c) in t.char_indices() {
|
||||
if units + c.len_utf16() > MAX_CRITERION_UTF16_UNITS {
|
||||
if units == MAX_CRITERION_UTF16_UNITS {
|
||||
return Criterion::parse_text(&t[..i]);
|
||||
}
|
||||
// The cut falls between the two halves of `c`.
|
||||
let (op, rest) = split_op(&t[..i]);
|
||||
return Criterion { op, operand: Operand::CutText(rest.to_string()), bare_empty: false };
|
||||
}
|
||||
units += c.len_utf16();
|
||||
}
|
||||
Criterion::parse_text(t)
|
||||
}
|
||||
other => Criterion::parse(other),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_text(t: &str) -> Criterion {
|
||||
if t.is_empty() {
|
||||
return Criterion { op: Op::Eq, operand: Operand::Blank, bare_empty: true };
|
||||
}
|
||||
let (op, rest) = split_op(t);
|
||||
let operand = if rest.is_empty() {
|
||||
Operand::Blank
|
||||
} else if let Some(n) = parse_number_text(rest) {
|
||||
@@ -98,9 +138,10 @@ impl Criterion {
|
||||
match self.op {
|
||||
Op::Eq => blank,
|
||||
Op::Ne => !blank,
|
||||
// "<" / ">" with nothing: Excel compares against "" for text cells.
|
||||
// "<" / ">" with nothing: Excel compares against "" for text cells, but an
|
||||
// empty string never matches ("<=" and ">=" count it in neither).
|
||||
_ => match &v {
|
||||
Value::Text(t) => cmp_op(self.op, compare_text(t, "")),
|
||||
Value::Text(t) if !t.is_empty() => cmp_op(self.op, compare_text(t, "")),
|
||||
_ => false,
|
||||
},
|
||||
}
|
||||
@@ -140,10 +181,35 @@ impl Criterion {
|
||||
_ => false,
|
||||
},
|
||||
},
|
||||
Operand::CutText(kept) => match self.op {
|
||||
Op::Eq => false,
|
||||
Op::Ne => true,
|
||||
Op::Lt | Op::Le => matches!(&v, Value::Text(t) if compare_text(t, kept) != Ordering::Greater),
|
||||
Op::Gt | Op::Ge => matches!(&v, Value::Text(t) if compare_text(t, kept) == Ordering::Greater),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits a leading comparison operator (`=` when there is none) off a text criterion.
|
||||
fn split_op(t: &str) -> (Op, &str) {
|
||||
if let Some(r) = t.strip_prefix(">=") {
|
||||
(Op::Ge, r)
|
||||
} else if let Some(r) = t.strip_prefix("<=") {
|
||||
(Op::Le, r)
|
||||
} else if let Some(r) = t.strip_prefix("<>") {
|
||||
(Op::Ne, r)
|
||||
} else if let Some(r) = t.strip_prefix('=') {
|
||||
(Op::Eq, r)
|
||||
} else if let Some(r) = t.strip_prefix('>') {
|
||||
(Op::Gt, r)
|
||||
} else if let Some(r) = t.strip_prefix('<') {
|
||||
(Op::Lt, r)
|
||||
} else {
|
||||
(Op::Eq, t)
|
||||
}
|
||||
}
|
||||
|
||||
fn cmp_op(op: Op, o: Ordering) -> bool {
|
||||
match op {
|
||||
Op::Eq => o == Ordering::Equal,
|
||||
@@ -168,19 +234,25 @@ fn tokenize(pattern: &str) -> Vec<Tok> {
|
||||
while let Some(c) = it.next() {
|
||||
match c {
|
||||
'~' => match it.next() {
|
||||
Some(n) => out.extend(n.to_lowercase().map(Tok::Char)),
|
||||
Some(n) => out.extend(std::iter::once(lower_char(n)).map(Tok::Char)),
|
||||
None => out.push(Tok::Char('~')),
|
||||
},
|
||||
'*' => out.push(Tok::Star),
|
||||
'?' => out.push(Tok::Any),
|
||||
c => out.extend(c.to_lowercase().map(Tok::Char)),
|
||||
c => out.extend(std::iter::once(lower_char(c)).map(Tok::Char)),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Lowercase one char to exactly one char, so positions in the lowered text match the original
|
||||
/// (U+0130 `İ` folds to `i`, like `fold` in text.rs).
|
||||
fn lower_char(c: char) -> char {
|
||||
c.to_lowercase().next().unwrap_or(c)
|
||||
}
|
||||
|
||||
fn lower_chars(s: &str) -> Vec<char> {
|
||||
s.chars().flat_map(char::to_lowercase).collect()
|
||||
s.chars().map(lower_char).collect()
|
||||
}
|
||||
|
||||
/// Whether the pattern contains an unescaped wildcard.
|
||||
@@ -290,6 +362,9 @@ mod tests {
|
||||
assert!(m("<>", Value::from("a")));
|
||||
assert!(!m("<>", Value::Empty));
|
||||
assert!(m("TRUE", Value::Bool(true)));
|
||||
// A bare "<=" or ">=" never matches an empty string (Excel 365: COUNTIF(="", "<=") is 0).
|
||||
assert!(!m("<=", Value::from("")));
|
||||
assert!(!m(">=", Value::from("")));
|
||||
assert!(m("<b", Value::from("apple")));
|
||||
assert!(!m("<b", Value::Number(1.0)));
|
||||
assert!(m("#N/A", Value::Error(CellError::NA)));
|
||||
@@ -317,6 +392,8 @@ mod tests {
|
||||
assert_eq!(wildcard_find("b?d", "abxxd bcd", 0), Some(6));
|
||||
assert_eq!(wildcard_find("c*", "abcd", 0), Some(2));
|
||||
assert_eq!(wildcard_find("z", "abcd", 0), None);
|
||||
assert_eq!(wildcard_find("b", "a\u{130}bc", 0), Some(2));
|
||||
assert!(wildcard_match("i*", "\u{130}stanbul"));
|
||||
assert!(has_wildcards("a*"));
|
||||
assert!(!has_wildcards("abc"));
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
//! The database and the criteria are arrays whose first row holds column labels. Each criteria
|
||||
//! row is a set of conditions that must all hold (AND); a record matches when any criteria row
|
||||
//! holds (OR). An empty criteria cell matches anything. Plain text conditions match values that
|
||||
//! begin with the text, as in Excel's advanced filter.
|
||||
//! begin with the text, as in Excel's advanced filter, so an empty string matches any text. A
|
||||
//! formula under a label that names no field is a computed criterion, which the host evaluates
|
||||
//! for each record ([`Ctx::computed_criterion`]).
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
@@ -49,7 +51,6 @@ fn field_index(db: &Array, a: &[Arg]) -> R<Option<usize>> {
|
||||
fn cell_criterion(v: &Value) -> Option<Criterion> {
|
||||
match v {
|
||||
Value::Empty => None,
|
||||
Value::Text(t) if t.is_empty() => None,
|
||||
Value::Text(t) => {
|
||||
let s: &str = t;
|
||||
let has_op = s.starts_with(['=', '<', '>']);
|
||||
@@ -65,9 +66,10 @@ fn cell_criterion(v: &Value) -> Option<Criterion> {
|
||||
}
|
||||
|
||||
/// Rows (1-based within the database, excluding the header) of matching records.
|
||||
fn matching_records(db: &Array, crit: &Array) -> Vec<usize> {
|
||||
// Per criteria row: list of (database column or None when the label is unknown, criterion).
|
||||
let mut rows: Vec<Vec<(Option<usize>, Criterion)>> = Vec::new();
|
||||
fn matching_records(db: &Array, crit: &Array, ctx: &mut dyn Ctx) -> Vec<usize> {
|
||||
// Per criteria row: its index and a list of (criteria column, database column or None when
|
||||
// the label is unknown, criterion).
|
||||
let mut rows: Vec<(usize, Vec<(usize, Option<usize>, Criterion)>)> = Vec::new();
|
||||
for r in 1..crit.rows {
|
||||
let mut conds = Vec::new();
|
||||
for c in 0..crit.cols {
|
||||
@@ -78,18 +80,20 @@ fn matching_records(db: &Array, crit: &Array) -> Vec<usize> {
|
||||
Some(Value::Number(n)) => find_column(db, &gridcraft_core::number_to_text(*n)),
|
||||
_ => None,
|
||||
};
|
||||
conds.push((col, cr));
|
||||
conds.push((c, col, cr));
|
||||
}
|
||||
rows.push(conds);
|
||||
rows.push((r, conds));
|
||||
}
|
||||
let all_match = rows.is_empty();
|
||||
(1..db.rows)
|
||||
.filter(|&r| {
|
||||
.filter(|&rec| {
|
||||
all_match
|
||||
|| rows.iter().any(|conds| {
|
||||
conds.iter().all(|(col, cr)| match col {
|
||||
Some(c) => cr.matches(db.get(r, *c).unwrap_or(&Value::Empty)),
|
||||
None => false,
|
||||
|| rows.iter().any(|(r, conds)| {
|
||||
conds.iter().all(|(c, col, cr)| match col {
|
||||
Some(f) => cr.matches(db.get(rec, *f).unwrap_or(&Value::Empty)),
|
||||
// Under a label that names no field, only a formula (a computed
|
||||
// criterion) can hold.
|
||||
None => ctx.computed_criterion(*r, *c, db, rec).unwrap_or(false),
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -102,7 +106,7 @@ struct Selection<'a> {
|
||||
records: Vec<usize>,
|
||||
}
|
||||
|
||||
fn select(a: &[Arg]) -> R<Selection<'_>> {
|
||||
fn select<'a>(a: &'a [Arg], ctx: &mut dyn Ctx) -> R<Selection<'a>> {
|
||||
let dbv = &a.first().ok_or(CellError::Value)?.value;
|
||||
let critv = &a.get(2).ok_or(CellError::Value)?.value;
|
||||
if let Value::Error(e) = dbv {
|
||||
@@ -114,31 +118,31 @@ fn select(a: &[Arg]) -> R<Selection<'_>> {
|
||||
let db = as_array(dbv);
|
||||
let crit = as_array(critv);
|
||||
let field = field_index(&db, a)?;
|
||||
let records = matching_records(&db, &crit);
|
||||
let records = matching_records(&db, &crit, ctx);
|
||||
Ok(Selection { db, field, records })
|
||||
}
|
||||
|
||||
/// Numbers of the field among matching records (field required).
|
||||
fn field_numbers(a: &[Arg]) -> R<Vec<f64>> {
|
||||
let s = select(a)?;
|
||||
fn field_numbers(a: &[Arg], c: &mut dyn Ctx) -> R<Vec<f64>> {
|
||||
let s = select(a, c)?;
|
||||
let f = s.field.ok_or(CellError::Value)?;
|
||||
Ok(s.records.iter().filter_map(|&r| s.db.get(r, f).and_then(Value::as_f64)).collect())
|
||||
}
|
||||
|
||||
fn dsum(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(field_numbers(a)?.iter().sum())
|
||||
fn dsum(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(field_numbers(a, c)?.iter().sum())
|
||||
}
|
||||
|
||||
fn daverage(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let v = field_numbers(a)?;
|
||||
fn daverage(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let v = field_numbers(a, c)?;
|
||||
if v.is_empty() {
|
||||
return Err(CellError::Div0);
|
||||
}
|
||||
num_val(v.iter().sum::<f64>() / v.len() as f64)
|
||||
}
|
||||
|
||||
fn dcount(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let s = select(a)?;
|
||||
fn dcount(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let s = select(a, c)?;
|
||||
let n = match s.field {
|
||||
Some(f) => s.records.iter().filter(|&&r| matches!(s.db.get(r, f), Some(Value::Number(_)))).count(),
|
||||
None => s.records.len(),
|
||||
@@ -146,8 +150,8 @@ fn dcount(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(n as f64)
|
||||
}
|
||||
|
||||
fn dcounta(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let s = select(a)?;
|
||||
fn dcounta(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let s = select(a, c)?;
|
||||
let n = match s.field {
|
||||
Some(f) => s.records.iter().filter(|&&r| !matches!(s.db.get(r, f), None | Some(Value::Empty))).count(),
|
||||
None => s.records.len(),
|
||||
@@ -155,28 +159,31 @@ fn dcounta(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(n as f64)
|
||||
}
|
||||
|
||||
fn dget(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let s = select(a)?;
|
||||
fn dget(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let s = select(a, c)?;
|
||||
let f = s.field.ok_or(CellError::Value)?;
|
||||
match s.records.as_slice() {
|
||||
[] => Err(CellError::Value),
|
||||
[r] => Ok(s.db.get(*r, f).cloned().unwrap_or(Value::Empty)),
|
||||
// Records whose field is blank (or "") don't count, as in Excel.
|
||||
let mut found =
|
||||
s.records.iter().filter_map(|&r| s.db.get(r, f)).filter(|v| !matches!(v, Value::Empty) && !matches!(v, Value::Text(t) if t.is_empty()));
|
||||
match (found.next(), found.next()) {
|
||||
(None, _) => Err(CellError::Value),
|
||||
(Some(v), None) => Ok(v.clone()),
|
||||
_ => Err(CellError::Num),
|
||||
}
|
||||
}
|
||||
|
||||
fn dmax(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let v = field_numbers(a)?;
|
||||
fn dmax(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let v = field_numbers(a, c)?;
|
||||
num_val(v.iter().copied().reduce(f64::max).unwrap_or(0.0))
|
||||
}
|
||||
|
||||
fn dmin(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let v = field_numbers(a)?;
|
||||
fn dmin(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let v = field_numbers(a, c)?;
|
||||
num_val(v.iter().copied().reduce(f64::min).unwrap_or(0.0))
|
||||
}
|
||||
|
||||
fn dproduct(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let v = field_numbers(a)?;
|
||||
fn dproduct(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let v = field_numbers(a, c)?;
|
||||
if v.is_empty() {
|
||||
return num_val(0.0);
|
||||
}
|
||||
@@ -194,20 +201,20 @@ fn variance(v: &[f64], sample: bool) -> R<f64> {
|
||||
Ok(ss / if sample { n - 1.0 } else { n })
|
||||
}
|
||||
|
||||
fn dvar(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(variance(&field_numbers(a)?, true)?)
|
||||
fn dvar(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(variance(&field_numbers(a, c)?, true)?)
|
||||
}
|
||||
|
||||
fn dvarp(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(variance(&field_numbers(a)?, false)?)
|
||||
fn dvarp(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(variance(&field_numbers(a, c)?, false)?)
|
||||
}
|
||||
|
||||
fn dstdev(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(variance(&field_numbers(a)?, true)?.sqrt())
|
||||
fn dstdev(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(variance(&field_numbers(a, c)?, true)?.sqrt())
|
||||
}
|
||||
|
||||
fn dstdevp(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(variance(&field_numbers(a)?, false)?.sqrt())
|
||||
fn dstdevp(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(variance(&field_numbers(a, c)?, false)?.sqrt())
|
||||
}
|
||||
|
||||
pub(crate) fn specs() -> Vec<FnSpec> {
|
||||
@@ -371,4 +378,69 @@ mod tests {
|
||||
is_err(ev("DSUM", vec![e(CellError::Ref), t("Yield"), rf(c("=Cherry"))]), CellError::Ref);
|
||||
close(ev("DCOUNTA", vec![rf(db()), empty(), rf(c("=Pear"))]), 2.0);
|
||||
}
|
||||
|
||||
/// Expected results are Excel's, from Apache POI's `DGet.xls` (rows 74-86).
|
||||
#[test]
|
||||
fn empty_string_criterion() {
|
||||
let db = arr(vec![
|
||||
vec![tv("Index"), tv("Value")],
|
||||
vec![tv("a"), tv("=")],
|
||||
vec![tv("b"), Value::Empty],
|
||||
vec![tv("c"), tv("")],
|
||||
vec![tv("d"), tv(" ")],
|
||||
vec![tv("f"), Value::Error(CellError::Value)],
|
||||
vec![tv("h"), tv("abc")],
|
||||
vec![tv("i"), nv(0.0)],
|
||||
]);
|
||||
let get = |index: &str| {
|
||||
let crit = arr(vec![vec![tv("Index"), tv("Value")], vec![tv(index), tv("")]]);
|
||||
ev("DGET", vec![rf(db.clone()), t("Index"), rf(crit)])
|
||||
};
|
||||
// Like any plain text, "" is a "begins with" pattern: it matches every text value.
|
||||
is_text(get("c"), "c");
|
||||
is_text(get("h"), "h");
|
||||
is_err(get("b"), CellError::Value);
|
||||
is_err(get("i"), CellError::Value);
|
||||
is_err(get("f"), CellError::Value);
|
||||
let all = arr(vec![vec![tv("Value")], vec![tv("")]]);
|
||||
close(ev("DCOUNTA", vec![rf(db.clone()), t("Index"), rf(all)]), 4.0);
|
||||
|
||||
// Under a label that names no field, "" is a condition that never holds.
|
||||
let db = arr(vec![vec![tv("One"), tv("Two"), tv("Three")], vec![tv("a"), tv("c"), tv("x")], vec![tv("b"), tv("d"), tv("y")]]);
|
||||
let get = |label: Value, value: Value| {
|
||||
let crit = arr(vec![vec![tv("One"), label, tv("Three")], vec![tv("a"), value, tv("x")]]);
|
||||
ev("DGET", vec![rf(db.clone()), t("One"), rf(crit)])
|
||||
};
|
||||
is_text(get(Value::Empty, Value::Empty), "a");
|
||||
is_text(get(tv(""), Value::Empty), "a");
|
||||
is_err(get(Value::Empty, tv("")), CellError::Value);
|
||||
is_err(get(tv(""), tv("")), CellError::Value);
|
||||
}
|
||||
|
||||
/// Expected results are Excel's, from Apache POI's `DGet.xls` (rows 108-119).
|
||||
#[test]
|
||||
fn dget_skips_empty_fields() {
|
||||
let db = arr(vec![
|
||||
vec![tv("One"), tv("Two")],
|
||||
vec![tv("a"), Value::Empty],
|
||||
vec![tv("b"), tv(" ")],
|
||||
vec![tv("c"), tv("")],
|
||||
vec![tv("d"), Value::Error(CellError::NA)],
|
||||
vec![tv("j"), Value::Empty],
|
||||
vec![tv("j"), tv("Text")],
|
||||
vec![tv("j"), Value::Empty],
|
||||
vec![tv("k"), Value::Empty],
|
||||
vec![tv("k"), tv("")],
|
||||
]);
|
||||
let get = |one: &str| {
|
||||
let crit = arr(vec![vec![tv("One")], vec![tv(one)]]);
|
||||
ev("DGET", vec![rf(db.clone()), t("Two"), rf(crit)])
|
||||
};
|
||||
is_err(get("a"), CellError::Value);
|
||||
is_text(get("b"), " ");
|
||||
is_err(get("c"), CellError::Value);
|
||||
is_err(get("d"), CellError::NA);
|
||||
is_text(get("j"), "Text");
|
||||
is_err(get("k"), CellError::Value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Date and time functions.
|
||||
|
||||
use gridcraft_core::date::{DateTime, datetime_from_serial, days_in_month, days_in_month_in, is_leap, serial_from_ymd};
|
||||
use gridcraft_core::date::{DateTime, datetime_from_serial, days_in_month, is_leap, serial_from_ymd, serial_in_system};
|
||||
use gridcraft_core::parse::parse_input;
|
||||
use gridcraft_core::{CellError, DateSystem, Value};
|
||||
|
||||
@@ -98,10 +98,10 @@ fn second(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
}
|
||||
|
||||
fn now(_a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(c.now_serial())
|
||||
num_val(serial_in_system(c.date_system(), c.now_serial()))
|
||||
}
|
||||
fn today(_a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
num_val(c.now_serial().floor())
|
||||
num_val(serial_in_system(c.date_system(), c.now_serial()).floor())
|
||||
}
|
||||
|
||||
fn weekday(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
@@ -164,7 +164,9 @@ fn edate(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
if !(1900..=9999).contains(&y) {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
let dd = (d.day.max(1) as i64).min(days_in_month_in(sys, y as i32, mo as u32) as i64);
|
||||
// Excel keeps serial 0's day 0 and gives February 1900 its real 28 days here, though the
|
||||
// sheet's calendar has a 29 February 1900 (serial 60).
|
||||
let dd = (d.day as i64).min(days_in_month(y as i32, mo as u32) as i64);
|
||||
num_val(ymd_serial(sys, y, mo, dd)? as f64)
|
||||
}
|
||||
|
||||
@@ -177,7 +179,8 @@ fn eomonth(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
if !(1900..=9999).contains(&y) {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
num_val(ymd_serial(sys, y, mo, days_in_month_in(sys, y as i32, mo as u32) as i64)? as f64)
|
||||
// Like EDATE, February 1900 ends on the 28th (serial 59).
|
||||
num_val(ymd_serial(sys, y, mo, days_in_month(y as i32, mo as u32) as i64)? as f64)
|
||||
}
|
||||
|
||||
fn datedif(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
@@ -680,17 +683,35 @@ mod tests {
|
||||
close(ev("DATEVALUE", vec![t("2/29/1900")]), 60.0);
|
||||
close(ev("DATEVALUE", vec![t("3/1/1900")]), 61.0);
|
||||
is_err(ev("DATEVALUE", vec![t("2/29/1901")]), CellError::Value);
|
||||
// EDATE and EOMONTH land on it as the end of February 1900.
|
||||
close(ev("EOMONTH", vec![n(d(1900.0, 1.0, 15.0)), n(1.0)]), 60.0);
|
||||
close(ev("EOMONTH", vec![n(60.0), n(0.0)]), 60.0);
|
||||
close(ev("EOMONTH", vec![n(d(1900.0, 3.0, 15.0)), n(-1.0)]), 60.0);
|
||||
// EDATE and EOMONTH read it as 29 February, but end February 1900 on the 28th.
|
||||
close(ev("EOMONTH", vec![n(d(1900.0, 1.0, 15.0)), n(1.0)]), 59.0);
|
||||
close(ev("EOMONTH", vec![n(60.0), n(0.0)]), 59.0);
|
||||
close(ev("EOMONTH", vec![n(d(1900.0, 3.0, 15.0)), n(-1.0)]), 59.0);
|
||||
close(ev("EOMONTH", vec![n(60.0), n(12.0)]), d(1901.0, 2.0, 28.0));
|
||||
close(ev("EDATE", vec![n(d(1900.0, 1.0, 31.0)), n(1.0)]), 60.0);
|
||||
close(ev("EDATE", vec![n(d(1900.0, 3.0, 31.0)), n(-1.0)]), 60.0);
|
||||
close(ev("EDATE", vec![n(d(1900.0, 1.0, 31.0)), n(1.0)]), 59.0);
|
||||
close(ev("EDATE", vec![n(d(1900.0, 3.0, 31.0)), n(-1.0)]), 59.0);
|
||||
close(ev("EDATE", vec![n(60.0), n(1.0)]), d(1900.0, 3.0, 29.0));
|
||||
close(ev("EDATE", vec![n(60.0), n(12.0)]), d(1901.0, 2.0, 28.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edate_eomonth_early_1900() {
|
||||
// Excel 365: serial 0 is 0 January 1900 and February 1900 has 28 days here.
|
||||
close(ev("EDATE", vec![n(0.0), n(0.0)]), 0.0);
|
||||
close(ev("EDATE", vec![n(0.0), n(1.0)]), 31.0);
|
||||
close(ev("EDATE", vec![n(0.0), n(12.0)]), 366.0);
|
||||
close(ev("EOMONTH", vec![n(0.0), n(1.0)]), 59.0);
|
||||
close(ev("EDATE", vec![n(31.0), n(1.0)]), 59.0);
|
||||
close(ev("EOMONTH", vec![n(59.0), n(0.0)]), 59.0);
|
||||
// Unchanged, and the same as Excel.
|
||||
close(ev("EOMONTH", vec![n(0.0), n(0.0)]), 31.0);
|
||||
is_err(ev("EOMONTH", vec![n(0.0), n(-1.0)]), CellError::Num);
|
||||
close(ev("EDATE", vec![n(59.0), n(1.0)]), 88.0);
|
||||
close(ev("EDATE", vec![n(60.0), n(1.0)]), 89.0);
|
||||
close(ev("EDATE", vec![n(61.0), n(-1.0)]), 32.0);
|
||||
close(ev("EOMONTH", vec![n(31.0), n(0.0)]), 31.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn datedif_units() {
|
||||
let s = n(d(2001.0, 6.0, 1.0));
|
||||
@@ -794,6 +815,11 @@ mod tests {
|
||||
close(crate::call(spec, &[n(0.0)], &mut ctx), 6.0);
|
||||
let spec = crate::lookup("YEAR").unwrap();
|
||||
close(crate::call(spec, &[n(0.0)], &mut ctx), 1904.0);
|
||||
// The clock is 2026-10-07 12:00; in the 1904 system that is serial 44840.5.
|
||||
close(crate::call(crate::lookup("NOW").unwrap(), &[], &mut ctx), 44840.5);
|
||||
let today = crate::call(crate::lookup("TODAY").unwrap(), &[], &mut ctx);
|
||||
close(today.clone(), 44840.0);
|
||||
close(crate::call(spec, &[crate::Arg::val(today)], &mut ctx), 2026.0);
|
||||
// No fictitious day here: 1904 is a real leap year and 1900 is before the epoch.
|
||||
let spec = crate::lookup("DATE").unwrap();
|
||||
close(crate::call(spec, &[n(1904.0), n(2.0), n(29.0)], &mut ctx), 59.0);
|
||||
|
||||
@@ -30,6 +30,11 @@ fn rows_of(a: &Array) -> Vec<Vec<Value>> {
|
||||
(0..a.rows).map(|r| (0..a.cols).map(|c| a.get(r, c).cloned().unwrap_or(Value::Empty)).collect()).collect()
|
||||
}
|
||||
|
||||
/// `#NUM!` when an `h` x `w` output would exceed the function cell budget.
|
||||
fn check_output_size(h: usize, w: usize) -> R<()> {
|
||||
if h.saturating_mul(w) > MAX_CELLS { Err(CellError::Num) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// Builds an array from rows (all rows padded to the widest with `pad`). Empty → `#CALC!`.
|
||||
fn from_rows(rows: Vec<Vec<Value>>, pad: &Value) -> R<Value> {
|
||||
let h = rows.len();
|
||||
@@ -37,9 +42,7 @@ fn from_rows(rows: Vec<Vec<Value>>, pad: &Value) -> R<Value> {
|
||||
if h == 0 || w == 0 {
|
||||
return Err(CellError::Calc);
|
||||
}
|
||||
if h.saturating_mul(w) > MAX_CELLS {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
check_output_size(h, w)?;
|
||||
let mut data = Vec::with_capacity(h * w);
|
||||
for mut r in rows {
|
||||
r.resize(w, pad.clone());
|
||||
@@ -321,16 +324,20 @@ fn vstack(args: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
fn hstack(args: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let mut arrays = Vec::new();
|
||||
let mut h = 0usize;
|
||||
let mut w = 0usize;
|
||||
let mut total = 0usize;
|
||||
for i in 0..args.len() {
|
||||
let a = arr_arg(args, i)?;
|
||||
h = h.max(a.rows);
|
||||
w = w.saturating_add(a.cols);
|
||||
total = total.saturating_add(a.data.len());
|
||||
if total > MAX_CELLS {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
arrays.push(a);
|
||||
}
|
||||
// The padded rectangle can be far larger than the inputs; refuse it before building any rows.
|
||||
check_output_size(h, w)?;
|
||||
let mut rows = vec![Vec::new(); h];
|
||||
for a in &arrays {
|
||||
for (r, row) in rows.iter_mut().enumerate() {
|
||||
@@ -785,6 +792,12 @@ fn groupby(args: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
from_rows(out, &Value::Empty)
|
||||
}
|
||||
|
||||
/// Height and width of a PIVOTBY grid: `header_rows` (one per column field, plus the value-name row) and
|
||||
/// `body_rows` (row keys plus the total row), each `lead` row-field cells wide plus `value_cols` cells per column key.
|
||||
fn pivot_dims(header_rows: usize, body_rows: usize, col_keys: usize, value_cols: usize, lead: usize) -> (usize, usize) {
|
||||
(header_rows.saturating_add(body_rows), col_keys.saturating_mul(value_cols).saturating_add(lead))
|
||||
}
|
||||
|
||||
fn pivotby(args: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let rf = arr_arg(args, 0)?;
|
||||
let cf = arr_arg(args, 1)?;
|
||||
@@ -801,9 +814,6 @@ fn pivotby(args: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let ckeys: Vec<Vec<Value>> = p.rows.iter().map(|&r| key_row(&cf, r)).collect();
|
||||
let rk = distinct_keys(&rkeys, if row_sort.unsigned_abs() as usize <= rf.cols { row_sort } else { 0 });
|
||||
let ck = distinct_keys(&ckeys, if col_sort.unsigned_abs() as usize <= cf.cols { col_sort } else { 0 });
|
||||
if rk.len().saturating_add(2).saturating_mul((ck.len() + 1).saturating_mul(vals.cols).saturating_add(rf.cols)) > MAX_CELLS {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
let mut cell: HashMap<(String, String), Vec<usize>> = HashMap::new();
|
||||
for ((r, c), &i) in rkeys.iter().zip(ckeys.iter()).zip(p.rows.iter()) {
|
||||
cell.entry((row_key(r), row_key(c))).or_default().push(i);
|
||||
@@ -832,6 +842,14 @@ fn pivotby(args: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
cols.insert(0, None);
|
||||
}
|
||||
let lead = rf.cols;
|
||||
let value_row = vals.cols > 1 || p.show_headers;
|
||||
let header_rows = cf.cols.saturating_add(usize::from(value_row));
|
||||
let body_rows = rk.len().saturating_add(usize::from(row_total != 0));
|
||||
// Reject before building any header or body row: every column-field header row is as wide as the whole grid.
|
||||
let (height, width) = pivot_dims(header_rows, body_rows, cols.len(), vals.cols, lead);
|
||||
if height.saturating_mul(width) > MAX_CELLS {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
let mut out: Vec<Vec<Value>> = Vec::new();
|
||||
// Column header rows: one per column field, plus value names when several value columns.
|
||||
for level in 0..cf.cols {
|
||||
@@ -847,7 +865,7 @@ fn pivotby(args: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
if vals.cols > 1 || p.show_headers {
|
||||
if value_row {
|
||||
let mut line: Vec<Value> = if p.show_headers { p.field_headers.iter().take(lead).cloned().collect() } else { vec![Value::Empty; lead] };
|
||||
line.resize(lead, Value::Empty);
|
||||
for _ in &cols {
|
||||
@@ -1132,6 +1150,17 @@ mod tests {
|
||||
is_err(ev("TOROW", vec![av(arr(vec![vec![Value::Empty]])), n(1.0)]), CellError::Calc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_size_checked_before_padding() {
|
||||
assert_eq!(super::check_output_size(1_000_001, 10), Err(CellError::Num));
|
||||
assert_eq!(super::check_output_size(usize::MAX, 2), Err(CellError::Num));
|
||||
assert_eq!(super::check_output_size(1_000_000, 10), Ok(()));
|
||||
// A 1M-row column beside a 9-wide row: ~1M input cells, but the padded result would be 10,000,010.
|
||||
let tall = Value::from(gridcraft_core::Array::column(vec![nv(1.0); 1_000_001]));
|
||||
let wide = row(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]);
|
||||
is_err(ev("HSTACK", vec![av(tall), av(wide)]), CellError::Num);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapping() {
|
||||
let r = ev("WRAPROWS", vec![av(row(&[1.0, 2.0, 3.0, 4.0, 5.0])), n(2.0)]);
|
||||
@@ -1225,4 +1254,29 @@ mod tests {
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pivotby_size_check_counts_column_header_rows() {
|
||||
// One row, n column fields and n value columns: n header rows + value-name row + body row + total row,
|
||||
// each 1 + 2n wide. The size checked up front matches the grid PIVOTBY builds.
|
||||
let dims = |k: u32, totals: bool| {
|
||||
let fields: Vec<f64> = (0..k).map(f64::from).collect();
|
||||
let mut args = vec![av(row(&[1.0])), av(row(&fields)), av(row(&fields)), t("SUM")];
|
||||
if !totals {
|
||||
args.extend([empty(), n(0.0), empty(), n(0.0)]);
|
||||
}
|
||||
let r = rows_of(&ev("PIVOTBY", args));
|
||||
(r.len(), r.first().map_or(0, Vec::len))
|
||||
};
|
||||
assert_eq!(dims(2, true), super::pivot_dims(3, 2, 2, 2, 1));
|
||||
assert_eq!(dims(10, true), (13, 21));
|
||||
assert_eq!(dims(10, true), super::pivot_dims(11, 2, 2, 10, 1));
|
||||
assert_eq!(dims(10, false), (12, 11));
|
||||
assert_eq!(dims(10, false), super::pivot_dims(11, 1, 1, 10, 1));
|
||||
// n = 2300: the 2300 header rows alone are 10.58M cells, so the check must count them before any is built.
|
||||
let (h, w) = super::pivot_dims(2301, 2, 2, 2300, 1);
|
||||
assert!(2300 * w > crate::util::MAX_CELLS && h * w > crate::util::MAX_CELLS);
|
||||
let wide: Vec<f64> = (0..2300).map(f64::from).collect();
|
||||
is_err(ev("PIVOTBY", vec![av(row(&[1.0])), av(row(&wide)), av(row(&wide)), t("SUM")]), CellError::Num);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,7 +321,8 @@ fn mirr(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
neg += x / (1.0 + fr).powi(i as i32);
|
||||
}
|
||||
}
|
||||
if pos == 0.0 || neg == 0.0 {
|
||||
// No positive flow is not an error in Excel: the formula below gives -1.
|
||||
if neg == 0.0 {
|
||||
return Err(CellError::Div0);
|
||||
}
|
||||
let r = (-pos * (1.0 + rr).powf(n - 1.0) / neg).powf(1.0 / (n - 1.0)) - 1.0;
|
||||
@@ -1813,6 +1814,8 @@ mod tests {
|
||||
close_tol(ev("MIRR", vec![rf(m), n(0.1), n(0.12)]), 0.1260943, 1e-6);
|
||||
close_tol(ev("MIRR", vec![rf(col(&[-120000.0, 39000.0, 30000.0, 21000.0])), n(0.1), n(0.12)]), -0.048044655, 1e-7);
|
||||
is_err(ev("MIRR", vec![rf(col(&[1.0, 2.0])), n(0.1), n(0.1)]), CellError::Div0);
|
||||
// Excel 365 gives -1 when every flow is negative: the positive flows are worth nothing.
|
||||
close_tol(ev("MIRR", vec![rf(col(&[-10000.0, -3400.0, -6500.0, -1000.0])), n(0.07), n(0.01)]), -1.0, 1e-12);
|
||||
close(ev("FVSCHEDULE", vec![n(1.0), av(row(&[0.09, 0.11, 0.1]))]), 1.33089);
|
||||
close(ev("EFFECT", vec![n(0.0525), n(4.0)]), 0.0535426673707584);
|
||||
is_err(ev("EFFECT", vec![n(-0.1), n(4.0)]), CellError::Num);
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use gridcraft_core::{CellError, DateSystem, Value};
|
||||
use gridcraft_core::{Array, CellError, DateSystem, Value};
|
||||
|
||||
/// Builds a [`FnSpec`] whose implementation returns `Result<Value, CellError>`.
|
||||
///
|
||||
@@ -105,6 +105,12 @@ pub trait Ctx {
|
||||
fn now_serial(&self) -> f64;
|
||||
/// Uniform in [0, 1).
|
||||
fn random(&mut self) -> f64;
|
||||
/// For the database functions: whether the formula in the criteria cell at (`row`, `col`) holds
|
||||
/// for `record` (a row of `db` below its labels). `None` when that cell holds no formula or the
|
||||
/// criteria didn't come from a reference.
|
||||
fn computed_criterion(&mut self, _row: usize, _col: usize, _db: &Array, _record: usize) -> Option<bool> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub type FnImpl = fn(&[Arg], &mut dyn Ctx) -> Value;
|
||||
@@ -197,7 +203,8 @@ fn registry() -> &'static (Vec<FnSpec>, HashMap<&'static str, usize>) {
|
||||
})
|
||||
}
|
||||
|
||||
/// Looks a function up by name (case-insensitive; `_xlfn.`, `_xlws.` and `_xludf.`-free names).
|
||||
/// Looks a function up by name (case-insensitive; `_xlfn.`- and `_xlws.`-free names). A
|
||||
/// `_xludf.` name is user-defined and never found.
|
||||
pub fn lookup(name: &str) -> Option<&'static FnSpec> {
|
||||
let mut n = name.trim().to_ascii_uppercase();
|
||||
loop {
|
||||
|
||||
@@ -75,7 +75,15 @@ fn round_dir(x: f64, digits: i32, up: bool) -> f64 {
|
||||
}
|
||||
let s = clean(scaled);
|
||||
let r = if up { s.ceil() } else { s.floor() };
|
||||
let out = if neg { r * f } else { clean(r / f) };
|
||||
// Toward zero, r == 0 with an infinite scale would be 0 * inf = NaN (#NUM!); the result is simply
|
||||
// zero. Away from zero the true result (10^309 or more) is not a number, so ROUNDUP stays #NUM!.
|
||||
let out = if !neg {
|
||||
clean(r / f)
|
||||
} else if r == 0.0 && !up {
|
||||
0.0
|
||||
} else {
|
||||
r * f
|
||||
};
|
||||
if x < 0.0 { -out } else { out }
|
||||
}
|
||||
|
||||
@@ -634,16 +642,18 @@ fn radians(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
|
||||
const NUMERALS: [(char, u32); 7] = [('I', 1), ('V', 5), ('X', 10), ('L', 50), ('C', 100), ('D', 500), ('M', 1000)];
|
||||
|
||||
/// Tokens (text, value) for a ROMAN form, largest value first. Form 0 is classic; higher forms
|
||||
/// allow subtracting progressively smaller numerals (max ratio 10, 20, 50, 100, 1000).
|
||||
/// Tokens (text, value) for a ROMAN form, largest value first. Form 0 is classic; each higher
|
||||
/// form widens one of two limits on the subtracted numeral's ratio to the larger one, alternating:
|
||||
/// the limit for I/X/C (10, 10, 100, 100, 1000) and for V/L (none, 20, 20, 200, 200). So form 2
|
||||
/// allows IC and XM but not VD, and form 3 adds VD and VM but not ID.
|
||||
fn roman_tokens(form: u32) -> Vec<(String, u32)> {
|
||||
let max_ratio = [10, 20, 50, 100, 1000].get(form as usize).copied().unwrap_or(10);
|
||||
let (pow10_max, five_max) = [(10, 0), (10, 20), (100, 20), (100, 200), (1000, 200)].get(form as usize).copied().unwrap_or((10, 0));
|
||||
let mut toks: Vec<(String, u32)> = NUMERALS.iter().map(|(c, v)| (c.to_string(), *v)).collect();
|
||||
for (i, (sc, sv)) in NUMERALS.iter().enumerate() {
|
||||
for (lc, lv) in NUMERALS.iter().skip(i + 1) {
|
||||
let ratio = lv / sv;
|
||||
let pow10 = matches!(sc, 'I' | 'X' | 'C');
|
||||
let ok = if form == 0 { pow10 && ratio <= 10 } else { ratio <= max_ratio };
|
||||
let ok = ratio <= if pow10 { pow10_max } else { five_max };
|
||||
let value = lv - sv;
|
||||
// Skip pairs that equal a single numeral (VX = V) – they are never shorter.
|
||||
if ok && !NUMERALS.iter().any(|(_, v)| *v == value) {
|
||||
@@ -950,6 +960,9 @@ fn criteria_mask(a: &[Arg], first_pair: usize, rows: usize, cols: usize) -> R<Ve
|
||||
if rest.is_empty() || rest.len() % 2 != 0 {
|
||||
return Err(CellError::Value);
|
||||
}
|
||||
// A text criterion over 255 characters is `#VALUE!` only when there is a single criteria
|
||||
// pair. With two or more pairs Excel matches its first 255 characters instead.
|
||||
let single_pair = rest.len() == 2;
|
||||
let mut mask = vec![true; rows.saturating_mul(cols)];
|
||||
for pair in rest.chunks(2) {
|
||||
let (Some(range), Some(crit)) = (pair.first(), pair.get(1)) else { return Err(CellError::Value) };
|
||||
@@ -957,7 +970,8 @@ fn criteria_mask(a: &[Arg], first_pair: usize, rows: usize, cols: usize) -> R<Ve
|
||||
if r.rows != rows || r.cols != cols {
|
||||
return Err(CellError::Value);
|
||||
}
|
||||
let c = Criterion::parse(&crit.value.scalar());
|
||||
let scalar = crit.value.scalar();
|
||||
let c = if single_pair { Criterion::parse_arg(&scalar)? } else { Criterion::parse_arg_truncated(&scalar) };
|
||||
for (m, v) in mask.iter_mut().zip(r.iter()) {
|
||||
if *m && !c.matches(v) {
|
||||
*m = false;
|
||||
@@ -1017,7 +1031,7 @@ fn minifs(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
|
||||
fn countif(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
let range = as_array(&crate::util::arg(a, 0)?.value);
|
||||
let c = Criterion::parse(&crate::util::arg(a, 1)?.value.scalar());
|
||||
let c = Criterion::parse_arg(&crate::util::arg(a, 1)?.value.scalar())?;
|
||||
num_val(range.iter().filter(|v| c.matches(v)).count() as f64)
|
||||
}
|
||||
|
||||
@@ -1026,7 +1040,7 @@ fn countif(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
/// the evaluator does that resizing (it has the references), so cells line up one to one here.
|
||||
fn if_numbers(a: &[Arg]) -> R<Vec<f64>> {
|
||||
let range = as_array(&crate::util::arg(a, 0)?.value);
|
||||
let c = Criterion::parse(&crate::util::arg(a, 1)?.value.scalar());
|
||||
let c = Criterion::parse_arg(&crate::util::arg(a, 1)?.value.scalar())?;
|
||||
let sum_range = if has(a, 2) { Some(as_array(&crate::util::arg(a, 2)?.value)) } else { None };
|
||||
let mut out = Vec::new();
|
||||
for r in 0..range.rows {
|
||||
@@ -1373,6 +1387,13 @@ mod tests {
|
||||
close(ev2("ROUNDDOWN", -3.9, 0.0), -3.0);
|
||||
close(ev2("ROUNDDOWN", 2.71989, 3.0), 2.719);
|
||||
close(ev2("ROUNDDOWN", 31415.92654, -2.0), 31400.0);
|
||||
for digits in [-308.0, -309.0, -400.0] {
|
||||
for x in [1234.0, -1234.0, 0.5] {
|
||||
close(ev2("ROUNDDOWN", x, digits), 0.0);
|
||||
close(ev2("TRUNC", x, digits), 0.0);
|
||||
}
|
||||
}
|
||||
assert!(matches!(ev2("ROUNDUP", 1234.0, -309.0), Value::Error(CellError::Num)));
|
||||
close(ev2("ROUNDUP", 0.1 + 0.2, 1.0), 0.3);
|
||||
close(ev2("TRUNC", 8.987, 2.0), 8.98);
|
||||
close(ev1("TRUNC", -8.9), -8.0);
|
||||
@@ -1553,6 +1574,43 @@ mod tests {
|
||||
is_err(ev("DECIMAL", vec![t("12"), n(2.0)]), Num);
|
||||
}
|
||||
|
||||
/// Every number where Excel's forms 2 and 3 differ from a plain ratio limit (issue #733):
|
||||
/// form 2 subtracts I from C and X from M, form 3 also V from M.
|
||||
#[test]
|
||||
fn roman_forms_two_and_three_match_excel() {
|
||||
const HUNDREDS: [&str; 10] = ["", "C", "CC", "CCC", "CD", "D", "DC", "DCC", "DCCC", "CM"];
|
||||
const ONES: [&str; 10] = ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX"];
|
||||
let mut cases = Vec::new();
|
||||
for thousands in 0..4usize {
|
||||
let m = "M".repeat(thousands);
|
||||
for (h, hundreds) in HUNDREDS.iter().enumerate() {
|
||||
if h != 4 && h != 9 {
|
||||
cases.push((thousands * 1000 + h * 100 + 99, 2.0, format!("{m}{hundreds}IC")));
|
||||
}
|
||||
}
|
||||
for (o, ones) in ONES.iter().enumerate() {
|
||||
cases.push((thousands * 1000 + 990 + o, 2.0, format!("{m}XM{ones}")));
|
||||
}
|
||||
for (o, ones) in ONES.iter().enumerate().take(5) {
|
||||
cases.push((thousands * 1000 + 995 + o, 3.0, format!("{m}VM{ones}")));
|
||||
}
|
||||
}
|
||||
assert_eq!(cases.len(), 92);
|
||||
for (number, form, want) in &cases {
|
||||
is_text(ev2("ROMAN", *number as f64, *form), want);
|
||||
}
|
||||
// Neighbours that already matched: form 2 still has no VD or ID, form 3 no ID.
|
||||
is_text(ev2("ROMAN", 495.0, 2.0), "XDV");
|
||||
is_text(ev2("ROMAN", 495.0, 3.0), "VD");
|
||||
is_text(ev2("ROMAN", 1499.0, 2.0), "MXDIX");
|
||||
is_text(ev2("ROMAN", 999.0, 0.0), "CMXCIX");
|
||||
is_text(ev2("ROMAN", 999.0, 1.0), "LMVLIV");
|
||||
is_text(ev2("ROMAN", 999.0, 4.0), "IM");
|
||||
is_text(ev2("ROMAN", 995.0, 4.0), "VM");
|
||||
is_text(ev2("ROMAN", 99.0, 1.0), "VCIV");
|
||||
is_text(ev2("ROMAN", 99.0, 4.0), "IC");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matrices() {
|
||||
let a = arr(vec![vec![nv(1.0), nv(2.0)], vec![nv(3.0), nv(4.0)]]);
|
||||
@@ -1643,4 +1701,77 @@ mod tests {
|
||||
is_err(ev("COUNTIFS", vec![rf(qty.clone()), t(">1"), rf(qty)]), CellError::Value);
|
||||
is_err(ev("SUMIF", vec![rf(col(&[1.0, 2.0])), t(">0"), rf(arr(vec![vec![Value::Error(NA)], vec![nv(1.0)]]))]), NA);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn criteria_over_255_chars_are_value_errors_with_one_pair_and_truncated_with_more() {
|
||||
let ok = "x".repeat(255);
|
||||
let long = "x".repeat(256);
|
||||
let cells = arr(vec![vec![tv(&ok)], vec![tv(&long)]]);
|
||||
let qty = col(&[1.0, 2.0]);
|
||||
close(ev("COUNTIF", vec![rf(cells.clone()), t(&ok)]), 1.0);
|
||||
is_err(ev("COUNTIF", vec![rf(cells.clone()), t(&long)]), CellError::Value);
|
||||
close(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&ok)]), 1.0);
|
||||
is_err(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long)]), CellError::Value);
|
||||
is_err(ev("COUNTIFS", vec![rf(cells.clone()), t(&long)]), CellError::Value);
|
||||
// With two or more pairs Excel matches the first 255 characters: the 255-character cell
|
||||
// (quantity 1), not the 256-character one (quantity 2). Checked on Excel 2016 and 365.
|
||||
let over = format!("{ok}y");
|
||||
close(ev("COUNTIFS", vec![rf(cells.clone()), t(&long), rf(qty.clone()), t(">0")]), 1.0);
|
||||
close(ev("SUMIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long), rf(qty.clone()), t(">0")]), 1.0);
|
||||
close(ev("SUMIFS", vec![rf(qty.clone()), rf(qty.clone()), t(">0"), rf(cells.clone()), t(&over)]), 1.0);
|
||||
close(ev("AVERAGEIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&long), rf(qty.clone()), t(">0")]), 1.0);
|
||||
close(ev("MAXIFS", vec![rf(qty.clone()), rf(cells.clone()), t(&"x".repeat(300)), rf(qty), t(">0")]), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn criteria_over_255_utf16_units_follow_excel() {
|
||||
// The tester's sheet, identical on Excel 2016 and Microsoft 365: A1:A6 hold these
|
||||
// strings, B is the row number, and each row uses its own string as the criterion.
|
||||
let e = "😀";
|
||||
let strings =
|
||||
[e.repeat(127), e.repeat(128), format!("{}{e}", "a".repeat(254)), format!("{}{e}", "a".repeat(253)), e.repeat(200), "я".repeat(256)];
|
||||
let cells = arr(strings.iter().map(|s| vec![tv(s)]).collect());
|
||||
let rows = col(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]);
|
||||
// COUNTIF, SUMIF, COUNTIFS with one pair, SUMIFS with a second pair `B,">0"`.
|
||||
let expected: [Option<[f64; 4]>; 6] = [Some([1.0, 1.0, 1.0, 1.0]), None, None, Some([1.0, 4.0, 1.0, 4.0]), None, None];
|
||||
for (s, want) in strings.iter().zip(expected) {
|
||||
let countif = ev("COUNTIF", vec![rf(cells.clone()), t(s)]);
|
||||
let sumif = ev("SUMIF", vec![rf(cells.clone()), t(s), rf(rows.clone())]);
|
||||
let countifs = ev("COUNTIFS", vec![rf(cells.clone()), t(s)]);
|
||||
let sumifs = ev("SUMIFS", vec![rf(rows.clone()), rf(cells.clone()), t(s), rf(rows.clone()), t(">0")]);
|
||||
match want {
|
||||
Some([a, b, c, d]) => {
|
||||
close(countif, a);
|
||||
close(sumif, b);
|
||||
close(countifs, c);
|
||||
close(sumifs, d);
|
||||
}
|
||||
// Over 255 units: `#VALUE!` with one pair; cut to 255 units with two, which
|
||||
// matches no cell (for emoji it splits a surrogate pair).
|
||||
None => {
|
||||
is_err(countif, CellError::Value);
|
||||
is_err(sumif, CellError::Value);
|
||||
is_err(countifs, CellError::Value);
|
||||
close(sumifs, 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn criteria_cut_inside_a_surrogate_pair() {
|
||||
let e = "😀";
|
||||
let cells = arr(vec![vec![tv(&e.repeat(127))], vec![tv(&e.repeat(128))], vec![tv("zzz")], vec![Value::Empty]]);
|
||||
let rows = col(&[1.0, 2.0, 3.0, 4.0]);
|
||||
let sumifs = |crit: String| ev("SUMIFS", vec![rf(rows.clone()), rf(cells.clone()), t(&crit), rf(rows.clone()), t(">0")]);
|
||||
// "=" + 😀×128 is 257 units: the cut after 255 keeps "=" and 127 whole emoji.
|
||||
close(sumifs(format!("={}", e.repeat(128))), 1.0);
|
||||
// With a two-unit prefix the cut keeps 126 emoji and half of the 127th. Such a criterion
|
||||
// equals no cell text: "=" matches nothing and "<>" everything, blanks included.
|
||||
close(sumifs(format!("=a{}", e.repeat(128))), 0.0);
|
||||
close(sumifs(format!("<>{}", e.repeat(128))), 10.0);
|
||||
// Comparisons treat it as sorting just above the 126 kept emoji.
|
||||
close(sumifs(format!(">={}", e.repeat(128))), 1.0 + 2.0);
|
||||
close(sumifs(format!("<={}", e.repeat(128))), 3.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ fn median(v: Vec<f64>) -> R<f64> {
|
||||
let s = sorted(v);
|
||||
let n = s.len();
|
||||
let hi = s.get(n / 2).copied().unwrap_or(0.0);
|
||||
if n % 2 == 1 { Ok(hi) } else { Ok((s.get(n / 2 - 1).copied().unwrap_or(0.0) + hi) / 2.0) }
|
||||
if n % 2 == 1 { Ok(hi) } else { Ok(s.get(n / 2 - 1).copied().unwrap_or(0.0).midpoint(hi)) }
|
||||
}
|
||||
|
||||
/// Values occurring at least twice, ordered by first occurrence, with their (equal, maximal) count.
|
||||
@@ -79,10 +79,12 @@ fn mode_sngl(v: &[f64]) -> R<f64> {
|
||||
}
|
||||
|
||||
/// Numbers for MODE / MODE.SNGL / MODE.MULT. Inside multi-cell ranges and arrays only numbers
|
||||
/// count, like [`numbers`]. Scalar arguments are stricter than in SUM: Excel answers `#VALUE!`
|
||||
/// for direct text (even numeric text such as `"1"`) and for a single-cell reference to text or
|
||||
/// a blank cell (observed: `MODE(B8,"1",2,3)` and `MODE(B9,2,D7)` with D7 blank are `#VALUE!`).
|
||||
/// Arguments are processed left to right, so the first error wins.
|
||||
/// count, like [`numbers`]. Scalar arguments differ from SUM: in Excel 365, text and logical
|
||||
/// values are skipped whether typed or referenced (even numeric text such as `"1"`, so
|
||||
/// `MODE(1,"1",2,3)` is `#N/A`, and `MODE(1,TRUE,TRUE,2)` is `#N/A`), but a single-cell reference
|
||||
/// to a blank cell is `#VALUE!` (`MODE(B9,2,D7)` with D7 blank). Arguments are processed left to
|
||||
/// right, so the first error wins. The results cached in POI's FormulaEvalTestData come from an
|
||||
/// older Excel that answered `#VALUE!` for typed text; they are not the reference here.
|
||||
fn mode_numbers(args: &[Arg]) -> R<Vec<f64>> {
|
||||
let mut out = Vec::new();
|
||||
for a in args {
|
||||
@@ -98,9 +100,8 @@ fn mode_numbers(args: &[Arg]) -> R<Vec<f64>> {
|
||||
}
|
||||
Value::Number(n) => out.push(*n),
|
||||
Value::Error(e) => return Err(*e),
|
||||
Value::Text(_) | Value::Empty => return Err(CellError::Value),
|
||||
Value::Bool(_) if a.from_ref => {}
|
||||
v => out.push(v.to_number()?),
|
||||
Value::Text(_) | Value::Bool(_) => {}
|
||||
Value::Empty => return Err(CellError::Value),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
@@ -461,7 +462,7 @@ fn prob(a: &[Arg], _: C) -> R<Value> {
|
||||
let (xs, ps) = arg_pairs(a, 0, 1)?;
|
||||
let lo = num(a, 2)?;
|
||||
let hi = if has(a, 3) { num(a, 3)? } else { lo };
|
||||
if ps.iter().any(|&p| !(0.0..=1.0).contains(&p)) {
|
||||
if ps.iter().any(|&p| p <= 0.0 || p > 1.0) {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
let total: f64 = ps.iter().sum();
|
||||
@@ -2254,6 +2255,46 @@ mod tests {
|
||||
av(col(xs))
|
||||
}
|
||||
|
||||
// The two middle values are finite and their midpoint is representable even when
|
||||
// adding them first would overflow. Exercise both callers of the shared helper.
|
||||
#[test]
|
||||
fn median_finite_midpoint_does_not_overflow() {
|
||||
for (values, expected) in [
|
||||
(vec![1e308, 1e308], 1e308),
|
||||
(vec![-1e308, -1e308], -1e308),
|
||||
(vec![1.6e308, 1e308], 1.3e308),
|
||||
(vec![-1e308, -1.6e308], -1.3e308),
|
||||
(vec![f64::MAX, f64::MAX], f64::MAX),
|
||||
(vec![-f64::MAX, -f64::MAX], -f64::MAX),
|
||||
(vec![-f64::MAX, f64::MAX], 0.0),
|
||||
(vec![1.7e308, -1.7e308, 1e308, 1.6e308], 1.3e308),
|
||||
] {
|
||||
close(ev("MEDIAN", values.iter().copied().map(n).collect()), expected);
|
||||
close(ev("MEDIAN", vec![rf(col(&values))]), expected);
|
||||
close(aggregate_values(12, &values.iter().copied().map(nv).collect::<Vec<_>>()), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn median_midpoint_keeps_small_values_and_argument_semantics() {
|
||||
// Halving each operand unconditionally can lose the smallest numbers. Use exact
|
||||
// equality here: the usual aggregate-test tolerance would accept zero.
|
||||
for value in [f64::MIN_POSITIVE, -f64::MIN_POSITIVE, f64::from_bits(1), -f64::from_bits(1)] {
|
||||
assert_eq!(ev("MEDIAN", vec![n(value), n(value)]), nv(value));
|
||||
assert_eq!(aggregate_values(12, &[nv(value), nv(value)]), nv(value));
|
||||
}
|
||||
close(ev("MEDIAN", vec![c(&[1.0, 2.0, 3.0, 4.0, 5.0, 6.0])]), 3.5);
|
||||
close(ev("MEDIAN", vec![c(&[1e308, -1e308, 3.0])]), 3.0);
|
||||
close(ev("MEDIAN", vec![n(1e308)]), 1e308);
|
||||
close(ev("MEDIAN", vec![t("4"), b(true)]), 2.5);
|
||||
close(ev("MEDIAN", vec![rf(arr(vec![vec![nv(1.0), tv("ignored"), Value::Empty, Value::Bool(true), nv(6.0)]]))]), 3.5);
|
||||
is_err(ev("MEDIAN", vec![t("invalid")]), CellError::Value);
|
||||
is_err(ev("MEDIAN", vec![e(CellError::NA), n(1e308)]), CellError::NA);
|
||||
is_err(ev("MEDIAN", vec![rf(arr(vec![vec![tv("ignored"), Value::Empty]]))]), CellError::Num);
|
||||
is_err(aggregate_values(12, &[]), CellError::Num);
|
||||
is_err(aggregate_values(12, &[nv(1e308), Value::Error(CellError::Div0)]), CellError::Div0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn averages_and_counts() {
|
||||
close(ev("AVERAGE", vec![n(1.0), n(2.0), n(6.0)]), 3.0);
|
||||
@@ -2279,9 +2320,11 @@ mod tests {
|
||||
assert_eq!(rows_of(&ev("MODE.MULT", vec![c(&[1.0, 2.0, 3.0, 4.0, 3.0, 2.0, 1.0, 2.0, 3.0])])), vec![vec![nv(2.0)], vec![nv(3.0)]]);
|
||||
}
|
||||
|
||||
/// MODE argument semantics observed in Excel (FormulaEvalTestData, row "Mode"): multi-cell
|
||||
/// ranges skip text and blanks, but direct text and single-cell references to text or blank
|
||||
/// cells are `#VALUE!`; the first error from the left wins.
|
||||
/// MODE argument semantics in Excel 365, on the inputs of FormulaEvalTestData (row "Mode"):
|
||||
/// text is skipped, typed or referenced, but a single-cell reference to a blank cell is
|
||||
/// `#VALUE!`; the first error from the left wins. The values cached in POI's file come from an
|
||||
/// older Excel and say `#VALUE!` for `MODE(C9,E16,"1",B8)`, `MODE(B8,"1",2,3)` and
|
||||
/// `MODE("1",1,#DIV/0!,2,3)`; Excel 365 gives `#N/A`, `#N/A` and `#DIV/0!`.
|
||||
#[test]
|
||||
fn mode_scalar_arguments() {
|
||||
let data = arr(vec![vec![tv(""), nv(0.0), nv(1.0)], vec![tv("x"), nv(1.0), nv(1.001)], vec![tv("y"), nv(9e9), nv(2.3456)]]);
|
||||
@@ -2290,9 +2333,9 @@ mod tests {
|
||||
(vec![rf(data)], Ok(1.0)),
|
||||
(vec![rf(arr(vec![vec![nv(-1.1), Value::Empty, tv("1.1")]])), n(-1.1)], Ok(-1.1)),
|
||||
// MODE(C9,E16,"1",B8): E16 holds the text "1.1".
|
||||
(vec![rf(nv(1.1)), rf(tv("1.1")), t("1"), rf(nv(1.0))], Err(CellError::Value)),
|
||||
(vec![rf(nv(1.0)), t("1"), n(2.0), n(3.0)], Err(CellError::Value)),
|
||||
(vec![t("1"), n(1.0), e(CellError::Div0), n(2.0), n(3.0)], Err(CellError::Value)),
|
||||
(vec![rf(nv(1.1)), rf(tv("1.1")), t("1"), rf(nv(1.0))], Err(CellError::NA)),
|
||||
(vec![rf(nv(1.0)), t("1"), n(2.0), n(3.0)], Err(CellError::NA)),
|
||||
(vec![t("1"), n(1.0), e(CellError::Div0), n(2.0), n(3.0)], Err(CellError::Div0)),
|
||||
// MODE(B9,2,D7) with D7 blank.
|
||||
(vec![rf(nv(2.0)), n(2.0), rf(Value::Empty)], Err(CellError::Value)),
|
||||
(vec![n(1.0), n(1.0), e(CellError::Div0), t("1")], Err(CellError::Div0)),
|
||||
@@ -2305,7 +2348,31 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
is_err(ev("MODE.MULT", vec![rf(nv(1.0)), t("1"), n(1.0)]), CellError::Value);
|
||||
is_err(ev("MODE.MULT", vec![rf(nv(1.0)), t("1"), rf(Value::Empty)]), CellError::Value);
|
||||
}
|
||||
|
||||
/// Text and logical values typed as arguments are skipped, not coerced (Excel 365, #736).
|
||||
#[test]
|
||||
fn mode_skips_typed_text_and_logicals() {
|
||||
let cases: Vec<(Vec<crate::Arg>, Result<f64, CellError>)> = vec![
|
||||
(vec![n(1.0), n(1.0), t("x")], Ok(1.0)),
|
||||
(vec![n(1.0), n(2.0), t("x")], Err(CellError::NA)),
|
||||
(vec![n(1.0), t("1"), n(2.0), n(3.0)], Err(CellError::NA)),
|
||||
(vec![t("1"), n(1.0), n(2.0), n(3.0)], Err(CellError::NA)),
|
||||
(vec![t("1"), n(1.0), e(CellError::Div0), n(2.0), n(3.0)], Err(CellError::Div0)),
|
||||
(vec![n(1.0), b(true), b(true), n(2.0)], Err(CellError::NA)),
|
||||
(vec![n(1.0), n(1.0), b(true)], Ok(1.0)),
|
||||
];
|
||||
for name in ["MODE", "MODE.SNGL"] {
|
||||
for (args, want) in &cases {
|
||||
match want {
|
||||
Ok(x) => close(ev(name, args.clone()), *x),
|
||||
Err(err) => is_err(ev(name, args.clone()), *err),
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(rows_of(&ev("MODE.MULT", vec![n(1.0), t("1"), n(1.0), t("x")])), vec![vec![nv(1.0)]]);
|
||||
is_err(ev("MODE.MULT", vec![n(1.0), b(true), b(true), n(2.0)]), CellError::NA);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2472,7 +2539,10 @@ mod tests {
|
||||
let p = c(&[0.2, 0.3, 0.1, 0.4]);
|
||||
close(ev("PROB", vec![x.clone(), p.clone(), n(2.0)]), 0.1);
|
||||
close(ev("PROB", vec![x.clone(), p, n(1.0), n(3.0)]), 0.8);
|
||||
is_err(ev("PROB", vec![x, c(&[0.5, 0.5, 0.5, 0.5]), n(1.0)]), CellError::Num);
|
||||
is_err(ev("PROB", vec![x.clone(), c(&[0.5, 0.5, 0.5, 0.5]), n(1.0)]), CellError::Num);
|
||||
// Excel: any probability <= 0 is #NUM!, even when it sums to one and lies outside the limits.
|
||||
is_err(ev("PROB", vec![x.clone(), c(&[0.5, 0.0, 0.25, 0.25]), n(0.0), n(3.0)]), CellError::Num);
|
||||
is_err(ev("PROB", vec![x, c(&[0.5, 0.0, 0.25, 0.25]), n(2.0), n(3.0)]), CellError::Num);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
//! Text functions.
|
||||
|
||||
use gridcraft_core::parse::parse_number_text;
|
||||
use gridcraft_core::parse::parse_number_text_in;
|
||||
use gridcraft_core::{CellError, Value, number_to_text};
|
||||
|
||||
use crate::criteria::wildcard_find;
|
||||
use crate::util::{
|
||||
A, MAX_TEXT, R, S, arg, array_val, as_array, boolean, has, num, num_val, opt_bool, opt_int, opt_num, round_half_away, scalar, text, text_val,
|
||||
A, MAX_CELLS, MAX_TEXT, R, S, arg, array_val, as_array, boolean, has, num, num_val, opt_bool, opt_int, opt_num, round_half_away, scalar, text,
|
||||
text_val,
|
||||
};
|
||||
use crate::{Arg, Ctx, FnSpec, VAR};
|
||||
|
||||
@@ -214,7 +215,7 @@ fn find_impl(a: &[Arg], wild: bool) -> R<Value> {
|
||||
let hay = text(a, 1)?;
|
||||
let start = opt_num(a, 2, 1.0)?;
|
||||
let hc = chars(&hay);
|
||||
if (start.is_nan() || start < 1.0) || start > hc.len() as f64 + 1.0 {
|
||||
if (start.is_nan() || start < 1.0) || start > hc.len().max(1) as f64 {
|
||||
return Err(CellError::Value);
|
||||
}
|
||||
let start = start as usize - 1;
|
||||
@@ -294,17 +295,17 @@ fn unicode(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
|
||||
/// VALUE: a blank cell reads as 0, but empty or blank text is not a number (observed:
|
||||
/// `VALUE("")` is `#VALUE!`).
|
||||
fn value(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
fn value(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
match scalar(arg(a, 0)?) {
|
||||
Value::Number(n) => num_val(n),
|
||||
Value::Empty => num_val(0.0),
|
||||
Value::Error(e) => Err(e),
|
||||
Value::Text(t) => parse_number_text(&t).ok_or(CellError::Value).and_then(num_val),
|
||||
Value::Text(t) => parse_number_text_in(&t, c.date_system()).ok_or(CellError::Value).and_then(num_val),
|
||||
_ => Err(CellError::Value),
|
||||
}
|
||||
}
|
||||
|
||||
fn numbervalue(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
fn numbervalue(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
||||
let s = text(a, 0)?;
|
||||
let dec = if a.len() > 1 { text(a, 1)?.chars().next().ok_or(CellError::Value)? } else { '.' };
|
||||
let grp = if a.len() > 2 { text(a, 2)?.chars().next().ok_or(CellError::Value)? } else { ',' };
|
||||
@@ -337,7 +338,7 @@ fn numbervalue(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
let mut n = parse_number_text(&out).ok_or(CellError::Value)?;
|
||||
let mut n = parse_number_text_in(&out, c.date_system()).ok_or(CellError::Value)?;
|
||||
for _ in 0..pct {
|
||||
n /= 100.0;
|
||||
}
|
||||
@@ -504,16 +505,33 @@ fn delimiters(v: &Value) -> R<Vec<Vec<char>>> {
|
||||
as_array(v).iter().map(|d| d.to_text().map(|s| chars(&s))).collect()
|
||||
}
|
||||
|
||||
/// Non-overlapping delimiter matches (start, end) in char positions, scanning left to right and
|
||||
/// preferring the longest delimiter at each position. Empty delimiters are ignored unless all
|
||||
/// Non-overlapping delimiter matches (start, end) in char positions, in ascending order. Scans left
|
||||
/// to right preferring the longest delimiter starting at each position, or (`from_end`) right to
|
||||
/// left preferring the longest delimiter ending at each position. Empty delimiters are ignored unless all
|
||||
/// delimiters are empty, in which case every position matches with zero length.
|
||||
fn find_matches(text: &[char], delims: &[Vec<char>], insensitive: bool) -> Vec<(usize, usize)> {
|
||||
fn find_matches(text: &[char], delims: &[Vec<char>], insensitive: bool, from_end: bool) -> Vec<(usize, usize)> {
|
||||
let t = if insensitive { fold(text) } else { text.to_vec() };
|
||||
let ds: Vec<Vec<char>> = delims.iter().filter(|d| !d.is_empty()).map(|d| if insensitive { fold(d) } else { d.clone() }).collect();
|
||||
if ds.is_empty() {
|
||||
return (0..=t.len()).map(|i| (i, i)).collect();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
if from_end {
|
||||
let mut j = t.len();
|
||||
while j > 0 {
|
||||
let best =
|
||||
ds.iter().filter(|d| j.checked_sub(d.len()).and_then(|st| t.get(st..j)).is_some_and(|w| w == d.as_slice())).map(|d| d.len()).max();
|
||||
match best {
|
||||
Some(l) => {
|
||||
out.push((j - l, j));
|
||||
j -= l;
|
||||
}
|
||||
None => j -= 1,
|
||||
}
|
||||
}
|
||||
out.reverse();
|
||||
return out;
|
||||
}
|
||||
let mut i = 0;
|
||||
while i < t.len() {
|
||||
let best = ds.iter().filter(|d| t.get(i..i + d.len()).is_some_and(|w| w == d.as_slice())).map(|d| d.len()).max();
|
||||
@@ -537,7 +555,7 @@ fn text_before_after(a: &[Arg], before: bool) -> R<Value> {
|
||||
if !(0..=1).contains(&mode) || inst == 0 || inst.unsigned_abs() as usize > s.len().max(1) {
|
||||
return Err(CellError::Value);
|
||||
}
|
||||
let mut m = find_matches(&s, &delims, mode == 1);
|
||||
let mut m = find_matches(&s, &delims, mode == 1, inst < 0);
|
||||
if match_end {
|
||||
if inst > 0 {
|
||||
if m.last().is_none_or(|x| x.0 != s.len()) {
|
||||
@@ -572,7 +590,7 @@ fn split_by(s: &[char], delims: &[Vec<char>], insensitive: bool) -> Vec<Vec<char
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut last = 0;
|
||||
for (st, en) in find_matches(s, delims, insensitive) {
|
||||
for (st, en) in find_matches(s, delims, insensitive, false) {
|
||||
out.push(s[last..st].to_vec());
|
||||
last = en;
|
||||
}
|
||||
@@ -610,6 +628,9 @@ fn textsplit(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
|
||||
}
|
||||
let ncols = rows.iter().map(Vec::len).max().unwrap_or(1).max(1);
|
||||
let nrows = rows.len();
|
||||
if nrows.saturating_mul(ncols) > MAX_CELLS {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
let mut data = Vec::with_capacity(nrows * ncols);
|
||||
for r in rows {
|
||||
let n = r.len();
|
||||
@@ -841,6 +862,7 @@ pub(crate) fn specs() -> Vec<FnSpec> {
|
||||
fixed
|
||||
),
|
||||
f!("DOLLAR", 1, 2, Text, S, "DOLLAR(number, [decimals])", "Formats a number as currency text.", dollar),
|
||||
f!("USDOLLAR", 1, 2, Compatibility, S, "USDOLLAR(number, [decimals])", "Formats a number as currency text, like DOLLAR.", dollar),
|
||||
f!("BAHTTEXT", 1, 1, Text, S, "BAHTTEXT(number)", "Spells out a number in Thai words with Baht.", bahttext),
|
||||
f!("T", 1, 1, Text, S, "T(value)", "Returns the value if it is text, otherwise empty text.", t_fn),
|
||||
f!(
|
||||
@@ -1032,6 +1054,15 @@ mod tests {
|
||||
close(ev("SEARCH", vec![t("~*"), t("a*b")]), 2.0);
|
||||
is_err(ev("SEARCH", vec![t("z"), t("abc")]), CellError::Value);
|
||||
is_err(ev("SEARCH", vec![t("a"), t("abc"), n(5.0)]), CellError::Value);
|
||||
close(ev("SEARCH", vec![t("b"), t("\u{130}b")]), 2.0);
|
||||
close(ev("SEARCH", vec![t("B"), t("\u{130}b")]), 2.0);
|
||||
close(ev("SEARCH", vec![t("b?"), t("a\u{130}bc")]), 3.0);
|
||||
close(ev("SEARCH", vec![t("i"), t("a\u{130}bc")]), 2.0);
|
||||
is_err(ev("SEARCH", vec![t("b"), t("a\u{130}bc"), n(4.0)]), CellError::Value);
|
||||
for f in ["FIND", "SEARCH"] {
|
||||
close(ev(f, vec![t(""), t("abc"), n(3.0)]), 3.0);
|
||||
is_err(ev(f, vec![t(""), t("abc"), n(4.0)]), CellError::Value);
|
||||
}
|
||||
assert_eq!(ev("EXACT", vec![t("a"), t("A")]), Value::Bool(false));
|
||||
assert_eq!(ev("EXACT", vec![t("a"), t("a")]), Value::Bool(true));
|
||||
}
|
||||
@@ -1091,6 +1122,8 @@ mod tests {
|
||||
is_text(ev("DOLLAR", vec![n(1234.567)]), "$1,234.57");
|
||||
is_text(ev("DOLLAR", vec![n(-1234.567), n(1.0)]), "($1,234.6)");
|
||||
is_text(ev("DOLLAR", vec![n(0.5), n(0.0)]), "$1");
|
||||
is_text(ev("USDOLLAR", vec![n(1.0)]), "$1.00");
|
||||
is_text(ev("USDOLLAR", vec![n(-1234.567), n(1.0)]), "($1,234.6)");
|
||||
is_text(ev("T", vec![t("x")]), "x");
|
||||
is_text(ev("T", vec![n(1.0)]), "");
|
||||
}
|
||||
@@ -1111,6 +1144,12 @@ mod tests {
|
||||
is_err(ev("TEXTBEFORE", vec![t("abc"), t("b"), n(0.0)]), CellError::Value);
|
||||
is_text(ev("TEXTBEFORE", vec![t("abc"), t("")]), "");
|
||||
is_text(ev("TEXTAFTER", vec![t("abc"), t("")]), "abc");
|
||||
// Negative instances search from the end, so overlapping delimiters match differently.
|
||||
is_text(ev("TEXTBEFORE", vec![t("aaa"), t("aa"), n(-1.0)]), "a");
|
||||
is_text(ev("TEXTAFTER", vec![t("aaa"), t("aa"), n(-1.0)]), "");
|
||||
is_text(ev("TEXTBEFORE", vec![t("aaaa"), t("aa"), n(-2.0)]), "");
|
||||
is_text(ev("TEXTBEFORE", vec![t("AaA"), t("aa"), n(-1.0), n(1.0)]), "A");
|
||||
is_text(ev("TEXTAFTER", vec![t("aaa"), t("aa"), n(1.0)]), "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1133,6 +1172,13 @@ mod tests {
|
||||
assert_eq!(rows_of(&v)[1], vec![tv("c"), tv("-")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_rejects_oversized_padded_grid() {
|
||||
// One row of 8000 columns followed by 8000 one-cell rows: the text is under the cell-text limit, the padded grid is not.
|
||||
let text = format!("{}{}", "a,".repeat(8000), "b;".repeat(8000));
|
||||
is_err(ev("TEXTSPLIT", vec![t(&text), t(","), t(";")]), CellError::Num);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_text() {
|
||||
is_text(ev("VALUETOTEXT", vec![t("a\"b")]), "a\"b");
|
||||
|
||||
@@ -202,6 +202,9 @@ fn basic_tools() {
|
||||
assert!(f.as_array().unwrap().iter().any(|x| x["name"] == "VLOOKUP"));
|
||||
assert_eq!(ok(&mut s, "evaluate_formula", json!({"formula": "SUM(1,2,3)"}))["result"], json!(6.0));
|
||||
ok(&mut s, "set_cell", json!({"cell": "A1", "input": "5"}));
|
||||
// #697: a number for `input` is entered, not rejected or cleared.
|
||||
ok(&mut s, "set_cell", json!({"cell": "B1", "input": 42.5}));
|
||||
assert_eq!(ok(&mut s, "get_cell", json!({"cell": "B1"}))["value"], json!(42.5));
|
||||
ok(&mut s, "execute_command", json!({"command": "cell.set", "params": {"cell": "A2", "input": "=A1*2"}}));
|
||||
assert_eq!(ok(&mut s, "get_cell", json!({"cell": "A2"}))["value"], json!(10.0));
|
||||
assert_eq!(ok(&mut s, "evaluate_formula", json!({"formula": "=A1+A2", "cell": "C1"}))["result"], json!(15.0));
|
||||
@@ -517,3 +520,29 @@ fn text_to_columns_destination_follows_its_sheet() {
|
||||
cmd_fails(&mut s, "data.textToColumns", json!({"range": "Other!A1", "delimiters": [","], "destination": "D0"}));
|
||||
cmd_fails(&mut s, "data.textToColumns", json!({"range": "Other!A1", "delimiters": [","], "destination": "Sheet1!D1"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_borders_are_invalid_params() {
|
||||
let mut s = server();
|
||||
let bad = |s: &mut Server, id: u64, borders: Value| {
|
||||
let v = rpc(s, id, "tools/call", json!({"name": "format_range", "arguments": {"range": "A1", "borders": borders}}));
|
||||
assert_eq!(err_code(&v), -32602, "{v}");
|
||||
};
|
||||
bad(&mut s, 1, json!(123));
|
||||
bad(&mut s, 2, json!("bogus"));
|
||||
bad(&mut s, 3, json!({"preset": "bogus"}));
|
||||
bad(&mut s, 4, json!({"style": "thick"}));
|
||||
ok(&mut s, "format_range", json!({"range": "A1", "borders": "all"}));
|
||||
ok(&mut s, "format_range", json!({"range": "B1", "borders": {"preset": "all", "style": "thick", "color": "#123456"}}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_sort_keys_are_invalid_params() {
|
||||
let mut s = server();
|
||||
ok(&mut s, "write_range", json!({"range": "A1", "values": [["b"], ["c"], ["a"]]}));
|
||||
for (id, keys) in [(1, json!([123])), (2, json!([{"order": "desc"}])), (3, json!([{"column": "A", "order": "sideways"}]))] {
|
||||
let v = rpc(&mut s, id, "tools/call", json!({"name": "sort_range", "arguments": {"range": "A1:A3", "keys": keys}}));
|
||||
assert_eq!(err_code(&v), -32602, "{v}");
|
||||
}
|
||||
ok(&mut s, "sort_range", json!({"range": "A1:A3", "keys": ["A", {"column": "A", "order": "desc"}]}));
|
||||
}
|
||||
|
||||
+39
-3
@@ -180,7 +180,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
"Enter one cell exactly as if typed: a number, text, or a formula starting with = (\"=AVERAGE(B2:B9)\"). \
|
||||
Dependent cells recalculate. Returns the command result; use get_cell to read the computed value.",
|
||||
obj(
|
||||
json!({"cell": string("Cell, e.g. \"B7\" or \"Sheet2!C3\""), "input": string("Typed input: number, text or =formula")}),
|
||||
json!({"cell": string("Cell, e.g. \"B7\" or \"Sheet2!C3\""), "input": {"type": ["string", "number", "boolean"], "description": "Typed input: number, text or =formula"}}),
|
||||
&["cell", "input"],
|
||||
),
|
||||
false,
|
||||
@@ -391,8 +391,41 @@ fn type_matches(t: &str, v: &Value) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates `args` against the tool's schema: unknown tool, missing required properties and
|
||||
/// top-level type mismatches are JSON-RPC "invalid params" errors.
|
||||
/// Checks a value against a (sub)schema: `type`, `enum`, `oneOf`, array `items`, and for objects the required and known properties.
|
||||
fn schema_matches(schema: &Value, v: &Value) -> bool {
|
||||
if let Some(t) = schema["type"].as_str()
|
||||
&& !type_matches(t, v)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(allowed) = schema["enum"].as_array()
|
||||
&& !allowed.contains(v)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(alts) = schema["oneOf"].as_array()
|
||||
&& !alts.iter().any(|alt| schema_matches(alt, v))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(xs) = v.as_array()
|
||||
&& !xs.iter().all(|x| schema_matches(&schema["items"], x))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(o) = v.as_object() {
|
||||
if schema["required"].as_array().into_iter().flatten().filter_map(Value::as_str).any(|r| o.get(r).is_none_or(Value::is_null)) {
|
||||
return false;
|
||||
}
|
||||
if let Some(props) = schema["properties"].as_object() {
|
||||
return o.iter().all(|(k, x)| props.get(k).is_some_and(|ps| x.is_null() || schema_matches(ps, x)));
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Validates `args` against the tool's schema: unknown tool, missing required properties,
|
||||
/// top-level type mismatches and values matching none of a `oneOf` are JSON-RPC "invalid params" errors.
|
||||
pub(crate) fn check_arguments(name: &str, args: &Value) -> Result<(), String> {
|
||||
let defs = tool_definitions();
|
||||
let def = defs.iter().find(|d| d["name"] == name).ok_or_else(|| format!("unknown tool `{name}`"))?;
|
||||
@@ -421,6 +454,9 @@ pub(crate) fn check_arguments(name: &str, args: &Value) -> Result<(), String> {
|
||||
{
|
||||
return Err(format!("{name}: `{k}` must be one of {}", Value::Array(allowed.clone())));
|
||||
}
|
||||
if (p["oneOf"].is_array() || p["items"]["oneOf"].is_array()) && !schema_matches(p, v) {
|
||||
return Err(format!("{name}: argument `{k}` does not match the schema: {}", p["description"]));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ mod tests {
|
||||
dates: vec![],
|
||||
},
|
||||
);
|
||||
s.hyperlinks.insert(c3, Hyperlink { target: "Sheet1!A1".into(), tooltip: Some("jump".into()) });
|
||||
s.hyperlinks.insert(c3, Hyperlink { target: "Sheet1!A1".into(), tooltip: Some("jump".into()), external: false });
|
||||
let json = serde_json::to_string(&s).expect("serialize");
|
||||
let back: Sheet = serde_json::from_str(&json).expect("deserialize");
|
||||
assert_eq!(back.comments, s.comments);
|
||||
|
||||
@@ -48,6 +48,23 @@ pub struct TableColumn {
|
||||
pub totals_label: Option<String>,
|
||||
/// Calculated column formula.
|
||||
pub formula: Option<String>,
|
||||
/// `uniqueName`: the name XML maps know the column by.
|
||||
#[serde(default)]
|
||||
pub unique_name: Option<String>,
|
||||
/// Binding to an element of an XML map. It moves with the column and goes when the column is
|
||||
/// deleted; inserted columns are unmapped.
|
||||
#[serde(default)]
|
||||
pub xml: Option<XmlColumnPr>,
|
||||
}
|
||||
|
||||
/// A table column's binding to an XML map (`<xmlColumnPr>`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct XmlColumnPr {
|
||||
pub map_id: u32,
|
||||
pub xpath: String,
|
||||
/// `xmlDataType` as written in the file (`string`, `boolean`, `integer`…).
|
||||
pub data_type: String,
|
||||
pub denormalized: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -70,6 +87,12 @@ pub struct Table {
|
||||
/// SUBTOTAL distinguishes.
|
||||
#[serde(default)]
|
||||
pub filter: Vec<(u32, FilterCriterion)>,
|
||||
/// `tableType` when it isn't a plain worksheet table: `xml` for a table bound to an XML map.
|
||||
#[serde(default)]
|
||||
pub table_type: Option<String>,
|
||||
/// The connection (`xl/connections.xml`) an XML table is bound through.
|
||||
#[serde(default)]
|
||||
pub connection_id: Option<u32>,
|
||||
}
|
||||
|
||||
impl Table {
|
||||
@@ -304,9 +327,14 @@ pub struct Comment {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Hyperlink {
|
||||
/// URL, `mailto:`, or an in-workbook location like `Sheet2!A1`.
|
||||
/// URL, `mailto:`, a file path (`Book.xlsx`, `Book.xlsx#Sheet1!A2`), or an in-workbook location
|
||||
/// like `Sheet2!A1`.
|
||||
pub target: String,
|
||||
pub tooltip: Option<String>,
|
||||
/// The target is outside this workbook (Excel's "Existing File or Web Page"): a relationship in
|
||||
/// the file, not a `location`. A bare file name can't be told from a defined name by its text.
|
||||
#[serde(default)]
|
||||
pub external: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- drawing objects
|
||||
@@ -536,7 +564,7 @@ pub enum ShapeTextAnchor {
|
||||
}
|
||||
|
||||
/// How a shape's outline and text look. The text properties apply to the whole text (the first
|
||||
/// run's properties when a file has several).
|
||||
/// paragraph's and first run's properties when a file has several).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ShapeFormat {
|
||||
@@ -556,6 +584,8 @@ pub struct ShapeFormat {
|
||||
pub align: Option<ShapeTextAlign>,
|
||||
/// `None`: the shape's default (top for text boxes, middle for other shapes).
|
||||
pub anchor: Option<ShapeTextAnchor>,
|
||||
/// Paragraphs run right to left (`a:pPr rtl`).
|
||||
pub rtl: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -975,6 +1005,12 @@ pub struct PivotTable {
|
||||
pub last_range: Option<RangeRef>,
|
||||
/// Calculated fields, usable in the Values area by name.
|
||||
pub calculated_fields: Vec<PivotCalcField>,
|
||||
/// The Σ Values pseudo-field (it exists with two or more value fields) sits in Rows rather
|
||||
/// than Columns (Excel's "values on rows").
|
||||
pub values_on_rows: bool,
|
||||
/// Position of Σ Values among the row fields (`None` = after the last). Only used with
|
||||
/// `values_on_rows`: in Columns Σ Values always comes after the last column field.
|
||||
pub values_position: Option<usize>,
|
||||
}
|
||||
|
||||
/// A calculated field: a formula over other fields (`=Sales*0.1`, `='Unit Price'*Units`). As in
|
||||
@@ -1007,6 +1043,8 @@ impl Default for PivotTable {
|
||||
show_headers: true,
|
||||
last_range: None,
|
||||
calculated_fields: vec![],
|
||||
values_on_rows: false,
|
||||
values_position: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ pub mod workbook;
|
||||
pub use cell::{Cell, CellPicture, Formula};
|
||||
pub use features::*;
|
||||
pub use password::PasswordHash;
|
||||
pub use preserved::{Preserved, PreservedPart, PreservedRel, PreservedXml};
|
||||
pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, SheetViewMode, Visibility};
|
||||
pub use preserved::{Preserved, PreservedPart, PreservedRel, PreservedSheet, PreservedXml};
|
||||
pub use sheet::{DEFAULT_COL_WIDTH, DEFAULT_ROW_HEIGHT, LineIndex, Sheet, SheetViewMode, Visibility, points_to_chars};
|
||||
pub use store::CellStore;
|
||||
pub use style::{
|
||||
Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, StyleId, StyleTable, Theme, Underline,
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::sheet::Visibility;
|
||||
|
||||
/// A relationship from an owning part (the package root, the workbook or a sheet, or a kept part)
|
||||
/// to a kept part or to an external target.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
@@ -33,15 +35,29 @@ pub struct PreservedPart {
|
||||
}
|
||||
|
||||
/// A raw XML element of an owning part that refers to kept parts, re-emitted on save with its
|
||||
/// relationship ids rewritten.
|
||||
/// relationship ids rewritten, or that Excel needs back unchanged (the workbook's `fileVersion`).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct PreservedXml {
|
||||
/// Which slot of the owner it belongs in (`externalReferences`, `oleObjects`, `controls`).
|
||||
/// Which slot of the owner it belongs in (`externalReferences`, `fileVersion`, `ext`,
|
||||
/// `oleObjects`, `controls`).
|
||||
pub slot: String,
|
||||
/// The element, namespace declarations it needs included.
|
||||
pub xml: String,
|
||||
}
|
||||
|
||||
/// A chart sheet (a sheet that holds one chart and no cells) kept as it was in the source file. Its
|
||||
/// part, drawing, chart, style and colour parts are in [`Preserved::parts`]; the workbook
|
||||
/// relationship to it is in [`Preserved::rels`].
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct PreservedSheet {
|
||||
pub name: String,
|
||||
pub visibility: Visibility,
|
||||
/// The id of the workbook relationship to the chart sheet part, in the source file.
|
||||
pub rel_id: String,
|
||||
/// How many worksheets come before it: its position among the model's sheets.
|
||||
pub before: usize,
|
||||
}
|
||||
|
||||
/// Unmodelled content kept for one owner (the workbook, which also holds package-root content,
|
||||
/// or one sheet).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
@@ -57,11 +73,18 @@ pub struct Preserved {
|
||||
/// The sheet's legacy VML drawing when kept elements need its shapes (form controls, OLE
|
||||
/// objects). Its own relationships point into `parts`.
|
||||
pub vml: Option<PreservedPart>,
|
||||
/// Chart sheets, in file order (workbook owner only).
|
||||
pub sheets: Vec<PreservedSheet>,
|
||||
}
|
||||
|
||||
impl Preserved {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.package_rels.is_empty() && self.rels.is_empty() && self.parts.is_empty() && self.elements.is_empty() && self.vml.is_none()
|
||||
self.package_rels.is_empty()
|
||||
&& self.rels.is_empty()
|
||||
&& self.parts.is_empty()
|
||||
&& self.elements.is_empty()
|
||||
&& self.vml.is_none()
|
||||
&& self.sheets.is_empty()
|
||||
}
|
||||
/// Total bytes held.
|
||||
pub fn byte_len(&self) -> usize {
|
||||
|
||||
@@ -117,6 +117,12 @@ pub struct Sheet {
|
||||
pub const DEFAULT_COL_WIDTH: f32 = 64.0;
|
||||
pub const DEFAULT_ROW_HEIGHT: f32 = 20.0;
|
||||
|
||||
/// Column width (points) → characters of the default font's digit width (7 px), without the
|
||||
/// cell padding: the value the Column Width dialog shows.
|
||||
pub fn points_to_chars(px: f64) -> f64 {
|
||||
if px <= 12.0 { (px / 12.0 * 100.0).round() / 100.0 } else { ((px - 5.0) / 7.0 * 100.0 + 0.5).trunc() / 100.0 }
|
||||
}
|
||||
|
||||
impl Sheet {
|
||||
pub fn new(name: impl Into<String>) -> Sheet {
|
||||
Sheet {
|
||||
|
||||
@@ -377,6 +377,12 @@ impl Default for StyleTable {
|
||||
}
|
||||
|
||||
impl StyleTable {
|
||||
/// A table whose default (Normal) style is `normal`, such as a file's own Normal font.
|
||||
pub fn with_default(normal: Style) -> Self {
|
||||
let mut index = HashMap::new();
|
||||
index.insert(normal.clone(), StyleId::DEFAULT);
|
||||
StyleTable { styles: vec![normal], index }
|
||||
}
|
||||
pub fn get(&self, id: StyleId) -> &Style {
|
||||
self.styles.get(id.0 as usize).or_else(|| self.styles.first()).unwrap_or(&DEFAULT_STYLE)
|
||||
}
|
||||
|
||||
@@ -116,6 +116,10 @@ pub struct Workbook {
|
||||
/// Unmodelled file content kept for saving (package root and workbook level). Not in JSON.
|
||||
#[serde(skip)]
|
||||
pub preserved: Option<Arc<crate::preserved::Preserved>>,
|
||||
/// The folder the workbook was read from, which relative paths to linked workbooks in
|
||||
/// `preserved` are relative to. Saving into another folder rewrites them from there. Not in JSON.
|
||||
#[serde(skip)]
|
||||
pub source_dir: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for Workbook {
|
||||
@@ -143,6 +147,7 @@ impl Workbook {
|
||||
vba_project: None,
|
||||
csv_delimiter: None,
|
||||
preserved: None,
|
||||
source_dir: None,
|
||||
}
|
||||
}
|
||||
pub fn sheet(&self, i: usize) -> Option<&Sheet> {
|
||||
|
||||
+105
-4
@@ -98,13 +98,38 @@ impl Default for NumberFormat {
|
||||
}
|
||||
}
|
||||
|
||||
/// Longest number format code, in characters (not bytes), that is parsed: Excel's own limit.
|
||||
/// A longer code is invalid: [`NumberFormat::parse`] returns a General format (the cell shows
|
||||
/// its value unformatted; the stored code is kept as is) and `TEXT` returns `#VALUE!`. The cap
|
||||
/// also bounds every allocation that scales with the number of placeholders in a code.
|
||||
pub const MAX_FORMAT_CODE_CHARS: usize = 255;
|
||||
|
||||
/// True when `code` is longer than [`MAX_FORMAT_CODE_CHARS`] characters.
|
||||
fn too_long(code: &str) -> bool {
|
||||
// A code of more than 255 bytes can still be short in characters; count only when needed.
|
||||
code.len() > MAX_FORMAT_CODE_CHARS && code.chars().count() > MAX_FORMAT_CODE_CHARS
|
||||
}
|
||||
|
||||
impl NumberFormat {
|
||||
/// Parses a format code. Never fails: unknown or malformed parts degrade to literal text.
|
||||
/// Parses a format code. Never fails: unknown or malformed parts degrade to literal text, and
|
||||
/// a code longer than [`MAX_FORMAT_CODE_CHARS`] characters degrades to General.
|
||||
pub fn parse(code: &str) -> NumberFormat {
|
||||
Self::parse_inner(code, false)
|
||||
}
|
||||
|
||||
/// `parse` without the length cap, so tests can reach renderer paths a capped code cannot.
|
||||
#[cfg(test)]
|
||||
fn parse_uncapped(code: &str) -> NumberFormat {
|
||||
Self::parse_inner(code, true)
|
||||
}
|
||||
|
||||
fn parse_inner(code: &str, uncapped: bool) -> NumberFormat {
|
||||
#[cfg(test)]
|
||||
parse_cache::count_parse();
|
||||
let trimmed = code.trim();
|
||||
// `checkbox` marks a checkbox cell (Insert › Checkbox): TRUE/FALSE draw as a box, any other
|
||||
// value displays as General.
|
||||
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("general") || trimmed == "checkbox" {
|
||||
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("general") || trimmed == "checkbox" || (!uncapped && too_long(code)) {
|
||||
return NumberFormat {
|
||||
inner: Arc::new(Inner { code: code.to_string(), general: true, sections: Vec::new(), num_sections: 0, text_section: None }),
|
||||
};
|
||||
@@ -261,7 +286,8 @@ impl NumberFormat {
|
||||
let Some(mut out) = out else {
|
||||
return (Formatted { text: OVERFLOW.into(), color: sec.color, fill: None, numeric: true }, true);
|
||||
};
|
||||
if minus && sec.kind != SecKind::Text {
|
||||
// Excel drops the minus sign when the first section shows a negative number as all zeros.
|
||||
if minus && sec.kind != SecKind::Text && !out.zero {
|
||||
out.prepend("-");
|
||||
}
|
||||
(Formatted { text: out.text, color: sec.color, fill: out.fill, numeric: true }, false)
|
||||
@@ -294,6 +320,78 @@ pub fn format_value(v: &Value, fmt: &NumberFormat, sys: DateSystem) -> Formatted
|
||||
}
|
||||
}
|
||||
|
||||
/// A small per-thread cache of parsed format codes for callers that format many values with the
|
||||
/// same code (`TEXT` over a range, chart labels). A parsed format depends on the code text only
|
||||
/// (the date system and values are applied when formatting), so the code is the whole key.
|
||||
mod parse_cache {
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::NumberFormat;
|
||||
|
||||
/// Most codes kept per thread. A full cache is emptied, so memory stays bounded.
|
||||
pub(super) const CAPACITY: usize = 64;
|
||||
/// Longer codes are parsed every time and never stored.
|
||||
pub(super) const MAX_CODE_LEN: usize = 256;
|
||||
|
||||
thread_local! {
|
||||
static CACHE: RefCell<HashMap<String, NumberFormat>> = RefCell::new(HashMap::new());
|
||||
}
|
||||
|
||||
pub(super) fn get(code: &str) -> NumberFormat {
|
||||
if code.len() > MAX_CODE_LEN {
|
||||
return NumberFormat::parse(code);
|
||||
}
|
||||
// Not usable (thread shutting down, re-entered): parse without the cache.
|
||||
let cached = CACHE
|
||||
.try_with(|c| {
|
||||
let mut c = c.try_borrow_mut().ok()?;
|
||||
if let Some(f) = c.get(code) {
|
||||
return Some(f.clone());
|
||||
}
|
||||
let f = NumberFormat::parse(code);
|
||||
if c.len() >= CAPACITY {
|
||||
c.clear();
|
||||
}
|
||||
c.insert(code.to_string(), f.clone());
|
||||
Some(f)
|
||||
})
|
||||
.ok()
|
||||
.flatten();
|
||||
cached.unwrap_or_else(|| NumberFormat::parse(code))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static PARSES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn count_parse() {
|
||||
let _ = PARSES.try_with(|p| p.set(p.get() + 1));
|
||||
}
|
||||
|
||||
/// Parses done on this thread so far (tests only).
|
||||
#[cfg(test)]
|
||||
pub(super) fn parses() -> usize {
|
||||
PARSES.try_with(std::cell::Cell::get).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Empties this thread's cache (tests only).
|
||||
#[cfg(test)]
|
||||
pub(super) fn clear() {
|
||||
let _ = CACHE.try_with(|c| c.borrow_mut().clear());
|
||||
}
|
||||
}
|
||||
|
||||
impl NumberFormat {
|
||||
/// Like [`NumberFormat::parse`], but remembers recently parsed codes (per thread, bounded) so
|
||||
/// formatting many values with one code parses it once. Same result as `parse`.
|
||||
pub fn parse_cached(code: &str) -> NumberFormat {
|
||||
parse_cache::get(code)
|
||||
}
|
||||
}
|
||||
|
||||
/// The `TEXT()` worksheet function: formats a value with a format code. Numeric text is
|
||||
/// converted to a number first; other text and booleans (as `TRUE`/`FALSE`) go through the text
|
||||
/// section, or are returned as is.
|
||||
@@ -301,7 +399,10 @@ pub fn text_function(v: &Value, code: &str, sys: DateSystem) -> Result<String, C
|
||||
if code.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
let fmt = NumberFormat::parse(code);
|
||||
if too_long(code) {
|
||||
return Err(CellError::Value);
|
||||
}
|
||||
let fmt = NumberFormat::parse_cached(code);
|
||||
let n = match v.scalar() {
|
||||
Value::Error(e) => return Err(e),
|
||||
Value::Empty => 0.0,
|
||||
|
||||
+45
-14
@@ -125,12 +125,20 @@ pub(crate) struct Section {
|
||||
}
|
||||
|
||||
/// Splits a code into sections at unquoted, unescaped, unbracketed semicolons.
|
||||
///
|
||||
/// A `[` opens a bracket only when a `]` follows it; the bracket then runs to the next `]` (quotes
|
||||
/// inside it are not special), exactly as `tokenize` reads it. An unclosed `[` is a literal in both
|
||||
/// passes, so a later `;` still separates sections. Linear: each `[` either jumps over its bracket
|
||||
/// or, with no `]` left in the code, is a literal at once.
|
||||
fn split_sections(code: &str) -> Vec<String> {
|
||||
let cs: Vec<char> = code.chars().collect();
|
||||
let last_close = cs.iter().rposition(|&c| c == ']');
|
||||
let mut out = vec![String::new()];
|
||||
let mut quoted = false;
|
||||
let mut bracket = false;
|
||||
let mut escape = false;
|
||||
for c in code.chars() {
|
||||
let mut i = 0usize;
|
||||
while let Some(&c) = cs.get(i) {
|
||||
i += 1;
|
||||
let cur = match out.last_mut() {
|
||||
Some(c) => c,
|
||||
None => break,
|
||||
@@ -145,19 +153,22 @@ fn split_sections(code: &str) -> Vec<String> {
|
||||
quoted = !quoted;
|
||||
cur.push(c);
|
||||
}
|
||||
'\\' | '_' | '*' if !quoted && !bracket => {
|
||||
'\\' | '_' | '*' if !quoted => {
|
||||
escape = true;
|
||||
cur.push(c);
|
||||
}
|
||||
'[' if !quoted => {
|
||||
bracket = true;
|
||||
'[' if !quoted && last_close.is_some_and(|l| l >= i) => {
|
||||
// `i` is already past the `[`; the closing bracket is at or after it.
|
||||
cur.push(c);
|
||||
while let Some(&d) = cs.get(i) {
|
||||
i += 1;
|
||||
cur.push(d);
|
||||
if d == ']' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
']' if !quoted => {
|
||||
bracket = false;
|
||||
cur.push(c);
|
||||
}
|
||||
';' if !quoted && !bracket => {
|
||||
';' if !quoted => {
|
||||
if out.len() >= MAX_SECTIONS {
|
||||
break;
|
||||
}
|
||||
@@ -246,6 +257,8 @@ fn tokenize(src: &str) -> Raw {
|
||||
let cs: Vec<char> = src.chars().collect();
|
||||
let mut raw = Raw { toks: Vec::new(), color: None, cond: None, currency: false };
|
||||
let toks = &mut raw.toks;
|
||||
// With no `]` at or after a `[`, that `[` is a literal; checking this once keeps the scan linear.
|
||||
let last_close = cs.iter().rposition(|&c| c == ']');
|
||||
let mut i = 0usize;
|
||||
while let Some(&c) = cs.get(i) {
|
||||
match c {
|
||||
@@ -290,7 +303,7 @@ fn tokenize(src: &str) -> Raw {
|
||||
continue;
|
||||
}
|
||||
'[' => {
|
||||
let close = cs.get(i + 1..).and_then(|r| r.iter().position(|&d| d == ']'));
|
||||
let close = if last_close.is_some_and(|l| l > i) { cs.get(i + 1..).and_then(|r| r.iter().position(|&d| d == ']')) } else { None };
|
||||
if let Some(p) = close {
|
||||
let content: String = cs.get(i + 1..i + 1 + p).unwrap_or(&[]).iter().collect();
|
||||
bracket(&mut raw_parts(toks, &mut raw.color, &mut raw.cond, &mut raw.currency), &content);
|
||||
@@ -513,6 +526,20 @@ fn analyze(raw: Raw) -> Section {
|
||||
let mut seen_exp = false;
|
||||
let mut seen_digit = false;
|
||||
let mut fraction_slash = false;
|
||||
// Precomputed so each comma and slash is O(1) (a long run of them stays linear): whether the
|
||||
// first non-comma token after a position is a digit, and where the last exponent marker is.
|
||||
let mut digit_after_commas = vec![false; toks.len()];
|
||||
let mut after = false;
|
||||
for (j, t) in toks.iter().enumerate().rev() {
|
||||
if let Some(slot) = digit_after_commas.get_mut(j) {
|
||||
*slot = after;
|
||||
}
|
||||
if !matches!(t, Tok::Comma) {
|
||||
after = matches!(t, Tok::Digit(_));
|
||||
}
|
||||
}
|
||||
let last_exp = toks.iter().rposition(|t| matches!(t, Tok::Exp { .. }));
|
||||
let scientific = toks.iter().skip_while(|t| !matches!(t, Tok::Digit(_))).any(|t| matches!(t, Tok::Exp { .. }));
|
||||
for i in 0..toks.len() {
|
||||
let Some(t) = toks.get(i).cloned() else { break };
|
||||
let replace = match t {
|
||||
@@ -542,11 +569,14 @@ fn analyze(raw: Raw) -> Section {
|
||||
}
|
||||
}
|
||||
Tok::Comma => {
|
||||
let next = toks.get(i + 1..).and_then(|s| s.iter().find(|t| !matches!(t, Tok::Comma)));
|
||||
let next_digit = matches!(next, Some(Tok::Digit(_)));
|
||||
let next_digit = digit_after_commas.get(i).copied().unwrap_or(false);
|
||||
if seen_digit && next_digit && !seen_point && !seen_exp {
|
||||
sec.thousands = true;
|
||||
Some(Tok::Lit(String::new()))
|
||||
} else if seen_digit && scientific {
|
||||
// Observed: a scientific section never scales; any other comma after the
|
||||
// first digit shows nothing (`#,e+#`, `#e+#,`, `#e+000,00`).
|
||||
Some(Tok::Lit(String::new()))
|
||||
} else if seen_digit && !next_digit {
|
||||
sec.scale += 1;
|
||||
Some(Tok::Lit(String::new()))
|
||||
@@ -558,7 +588,8 @@ fn analyze(raw: Raw) -> Section {
|
||||
// Literal text may sit between the bar and the denominator (`#/ #`, `# ?=/=?`).
|
||||
let next_ok = matches!(toks.get(i + 1), Some(Tok::FixedDenom(_)))
|
||||
|| matches!(toks.get(i + 1..).and_then(|s| s.iter().find(|t| !matches!(t, Tok::Lit(_)))), Some(Tok::Digit(_)));
|
||||
let has_exp = toks.iter().any(|t| matches!(t, Tok::Exp { .. }));
|
||||
// An exponent kept before this bar (`seen_exp`) or any marker still ahead of it.
|
||||
let has_exp = seen_exp || last_exp.is_some_and(|l| l > i);
|
||||
if !fraction_slash && seen_digit && next_ok && !seen_point && !has_exp {
|
||||
fraction_slash = true;
|
||||
sec.fraction = true;
|
||||
|
||||
+31
-16
@@ -1,7 +1,7 @@
|
||||
//! Rendering a value with a parsed section.
|
||||
|
||||
use gridcraft_core::DateSystem;
|
||||
use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial};
|
||||
use gridcraft_core::date::{MONTHS, WEEKDAYS, datetime_from_serial, max_serial};
|
||||
|
||||
use crate::decimal::{decimal_digits, fixed, fixed_from_digits, format_general_fit};
|
||||
use crate::parse::{DatePart, Section, Tok};
|
||||
@@ -11,6 +11,8 @@ use crate::parse::{DatePart, Section, Tok};
|
||||
pub(crate) struct Out {
|
||||
pub text: String,
|
||||
pub fill: Option<(char, usize)>,
|
||||
/// True when every digit shown is zero (a number section that rounded to zero).
|
||||
pub zero: bool,
|
||||
}
|
||||
|
||||
impl Out {
|
||||
@@ -58,7 +60,7 @@ pub(crate) fn render_general(sec: &Section, v: f64, width: usize) -> Out {
|
||||
|
||||
/// Renders a date/time. `None` when the serial is outside the supported range.
|
||||
pub(crate) fn render_date(sec: &Section, v: f64, sys: DateSystem) -> Option<Out> {
|
||||
if !v.is_finite() || !(0.0..2_958_466.0).contains(&v) {
|
||||
if !v.is_finite() || !(0.0..max_serial(sys) as f64 + 1.0).contains(&v) {
|
||||
return None;
|
||||
}
|
||||
let sub_digits = sec.toks.iter().filter_map(|t| if let Tok::Date(DatePart::SubSec(n)) = t { Some(*n) } else { None }).max().unwrap_or(0).min(3);
|
||||
@@ -231,7 +233,9 @@ fn digit_indices(toks: &[Tok], range: std::ops::Range<usize>) -> Vec<usize> {
|
||||
/// Renders a non-negative number through a number section.
|
||||
pub(crate) fn render_number(sec: &Section, v: f64) -> Out {
|
||||
let mut x = v.abs();
|
||||
if sec.percent > 0 {
|
||||
// Observed: `%` in a scientific section is only a literal (`#%e+#` shows 123456.789 as
|
||||
// `1%e+5`).
|
||||
if sec.percent > 0 && !sec.exp {
|
||||
x *= 100f64.powi(sec.percent.min(20));
|
||||
}
|
||||
if sec.scale > 0 {
|
||||
@@ -247,6 +251,7 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out {
|
||||
let mut pieces: Vec<String> = vec![String::new(); n];
|
||||
let mut before: Vec<String> = vec![String::new(); n + 1];
|
||||
let mut lits: Vec<Option<String>> = Vec::new();
|
||||
let mut zero = false;
|
||||
if sec.fraction {
|
||||
lits = vec![None; n];
|
||||
render_fraction(sec, x, &mut pieces, &mut lits);
|
||||
@@ -264,7 +269,9 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out {
|
||||
let exp_idx = digit_indices(toks, e_at + 1..n);
|
||||
let step = int_idx.len().max(1) as i32;
|
||||
let (mut e, int_s, frac_s) = if x == 0.0 {
|
||||
(0i32, String::new(), "0".repeat(frac_idx.len()))
|
||||
// Observed: zero shows `0` in every integer placeholder, `#` and `?` too
|
||||
// (`#.#E+#` gives `0.E+0`, `####.####E+#` gives `0000.E+0`).
|
||||
(0i32, "0".repeat(int_idx.len()), "0".repeat(frac_idx.len()))
|
||||
} else {
|
||||
let (digits, xe) = decimal_digits(x);
|
||||
let mut e = xe - xe.rem_euclid(step);
|
||||
@@ -283,26 +290,30 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out {
|
||||
if int_s.is_empty() && frac_s.chars().all(|c| c == '0') {
|
||||
e = 0;
|
||||
}
|
||||
let mut head = String::from(if upper { "E" } else { "e" });
|
||||
if e < 0 {
|
||||
head.push('-');
|
||||
let head = if upper { "E" } else { "e" };
|
||||
let sign = if e < 0 {
|
||||
"-"
|
||||
} else if plus {
|
||||
head.push('+');
|
||||
}
|
||||
let width = exp_idx.iter().filter(|&&i| matches!(toks.get(i), Some(Tok::Digit('0')))).count().min(20);
|
||||
let digits = format!("{:0w$}", e.unsigned_abs(), w = width);
|
||||
"+"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let digits = e.unsigned_abs().to_string();
|
||||
// The sign sits right before the exponent digits, after any literal that follows the
|
||||
// `E` (observed: `|0|e+|0|` shows `|1|e|+5|`); `?` pads the digits with spaces.
|
||||
match exp_idx.first() {
|
||||
Some(&first) => {
|
||||
if let Some(p) = pieces.get_mut(e_at) {
|
||||
*p = head;
|
||||
*p = head.to_string();
|
||||
}
|
||||
if let Some(p) = pieces.get_mut(first) {
|
||||
*p = digits;
|
||||
if let Some(b) = before.get_mut(first) {
|
||||
*b = sign.to_string();
|
||||
}
|
||||
fill_int(toks, &exp_idx, &digits, false, false, &mut pieces);
|
||||
}
|
||||
None => {
|
||||
if let Some(p) = pieces.get_mut(e_at) {
|
||||
*p = head + &digits;
|
||||
*p = format!("{head}{sign}{digits}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -310,6 +321,7 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out {
|
||||
} else {
|
||||
fixed(x, frac_idx.len())
|
||||
};
|
||||
zero = int_s.chars().chain(frac_s.chars()).all(|c| c == '0');
|
||||
fill_int(toks, &int_idx, &int_s, sec.thousands, false, &mut pieces);
|
||||
if int_idx.is_empty()
|
||||
&& !int_s.is_empty()
|
||||
@@ -338,6 +350,7 @@ pub(crate) fn render_number(sec: &Section, v: f64) -> Out {
|
||||
_ => out.push(pieces.get(i).map(String::as_str).unwrap_or("")),
|
||||
}
|
||||
}
|
||||
out.zero = zero;
|
||||
out
|
||||
}
|
||||
|
||||
@@ -496,7 +509,9 @@ fn render_fraction(sec: &Section, x: f64, pieces: &mut [String], lits: &mut [Opt
|
||||
// Denominator digits are zero padded to the `0` placeholders, then left aligned; `?`
|
||||
// pads with spaces after them.
|
||||
let zeros = den_idx.iter().filter(|&&i| toks.get(i) == Some(&Tok::Digit('0'))).count();
|
||||
let padded = format!("{den_s:0>zeros$}");
|
||||
// No `{:0>zeros$}`: a format width above u16::MAX panics.
|
||||
let mut padded = "0".repeat(zeros.saturating_sub(den_s.chars().count()));
|
||||
padded.push_str(&den_s);
|
||||
let dc: Vec<char> = padded.chars().collect();
|
||||
let count = den_idx.len();
|
||||
for (k, &ti) in den_idx.iter().enumerate() {
|
||||
|
||||
+283
-1
@@ -24,7 +24,7 @@ fn numbers() {
|
||||
assert_eq!(f("0", -2.5), "-3");
|
||||
assert_eq!(f("0.00", 2.675), "2.68");
|
||||
assert_eq!(f("0.00", 1.005), "1.01");
|
||||
assert_eq!(f("0.0", -0.04), "-0.0");
|
||||
assert_eq!(f("0.0", -0.04), "0.0");
|
||||
assert_eq!(f("00000", 123.0), "00123");
|
||||
assert_eq!(f("000-00-0000", 123456789.0), "123-45-6789");
|
||||
assert_eq!(f("#.##", 0.5), ".5");
|
||||
@@ -105,6 +105,29 @@ fn scientific() {
|
||||
assert_eq!(f("0.00E+00", -12345.0), "-1.23E+04");
|
||||
}
|
||||
|
||||
/// A negative number that rounds to zero in the first section shows no minus sign; a negative
|
||||
/// section of its own keeps its text (observed Excel results).
|
||||
#[test]
|
||||
fn negative_rounding_to_zero_drops_the_sign() {
|
||||
let cases: &[(&str, f64, &str)] = &[
|
||||
("0.00", -0.0033, "0.00"),
|
||||
("0.00", -2.89e-9, "0.00"),
|
||||
("0.00", -0.005, "-0.01"),
|
||||
("0", -0.4, "0"),
|
||||
("0", -0.5, "-1"),
|
||||
("0.0", -0.004, "0.0"),
|
||||
("#,##0.00", -0.004, "0.00"),
|
||||
("0.00%", -2.89e-9, "0.00%"),
|
||||
("0.00%", -0.0033, "-0.33%"),
|
||||
("0.00;(0.00)", -0.0033, "(0.00)"),
|
||||
("0.00;-0.00;\"zero\"", -0.0033, "-0.00"),
|
||||
("0.00E+00", -2.89e-9, "-2.89E-09"),
|
||||
];
|
||||
for &(code, n, want) in cases {
|
||||
assert_eq!(f(code, n), want, "{code} with {n}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fractions() {
|
||||
assert_eq!(f("# ?/?", 1.5), "1 1/2");
|
||||
@@ -247,6 +270,11 @@ fn dates_and_times() {
|
||||
assert_eq!(f("m/d/yyyy", 60.0), "2/29/1900");
|
||||
assert_eq!(f("m/d/yyyy", 0.0), "1/0/1900");
|
||||
assert_eq!(format_value(&Value::Number(0.0), &NumberFormat::parse("m/d/yyyy"), DateSystem::D1904).text, "1/1/1904");
|
||||
let fmt = NumberFormat::parse("m/d/yyyy");
|
||||
assert_eq!(format_value(&Value::Number(2957003.5), &fmt, DateSystem::D1904).text, "12/31/9999");
|
||||
assert_eq!(format_value(&Value::Number(2957004.0), &fmt, DateSystem::D1904).text, "########");
|
||||
assert_eq!(format_value(&Value::Number(2958465.0), &fmt, DateSystem::D1900).text, "12/31/9999");
|
||||
assert_eq!(format_value(&Value::Number(2958466.0), &fmt, DateSystem::D1900).text, "########");
|
||||
assert_eq!(f("m/d/yyyy", -1.0), "########");
|
||||
assert_eq!(f("m/d/yyyy", 3e6), "########");
|
||||
assert_eq!(f("[$-409]m/d/yyyy", 46302.0), "10/7/2026");
|
||||
@@ -339,7 +367,11 @@ fn text_fn() {
|
||||
assert_eq!(text_function(&Value::Empty, "0.00", S), Ok("0.00".into()));
|
||||
assert_eq!(text_function(&Value::Number(5.0), "", S), Ok("".into()));
|
||||
assert_eq!(text_function(&Value::Number(-1.0), "m/d/yyyy", S), Err(CellError::Value));
|
||||
assert_eq!(text_function(&Value::Number(2957003.0), "m/d/yyyy", DateSystem::D1904), Ok("12/31/9999".into()));
|
||||
assert_eq!(text_function(&Value::Number(2957004.0), "m/d/yyyy", DateSystem::D1904), Err(CellError::Value));
|
||||
assert_eq!(text_function(&Value::Bool(true), "0", S), Ok("TRUE".into()));
|
||||
assert_eq!(text_function(&Value::Number(-0.0033), "0", S), Ok("0".into()));
|
||||
assert_eq!(text_function(&Value::Number(-0.4), "0", S), Ok("0".into()));
|
||||
// Booleans go through the text section as TRUE/FALSE (observed Excel results).
|
||||
let cases: &[(bool, &str, &str)] = &[
|
||||
(true, ";;;@", "TRUE"),
|
||||
@@ -582,3 +614,253 @@ fn decimal_digits_keeps_fifteen_digits_and_truncates_a_tie() {
|
||||
let (d, e) = decimal_digits(999999999999999.6);
|
||||
assert_eq!((d, e), ([1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 15));
|
||||
}
|
||||
|
||||
// ---- parse cache ----
|
||||
|
||||
/// `TEXT()` as it was before the cache: parse the code, then format.
|
||||
fn text_uncached(v: &Value, code: &str, sys: DateSystem) -> Result<String, CellError> {
|
||||
if code.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
let fmt = NumberFormat::parse(code);
|
||||
let n = match v.scalar() {
|
||||
Value::Error(e) => return Err(e),
|
||||
Value::Empty => 0.0,
|
||||
Value::Number(n) => n,
|
||||
Value::Bool(b) => return Ok(fmt.format_text(if b { "TRUE" } else { "FALSE" }).text),
|
||||
Value::Text(t) => match gridcraft_core::parse::parse_number_text(&t) {
|
||||
Some(n) => n,
|
||||
None => return Ok(fmt.format_text(&t).text),
|
||||
},
|
||||
Value::Array(_) => return Err(CellError::Value),
|
||||
};
|
||||
if !n.is_finite() {
|
||||
return Err(CellError::Num);
|
||||
}
|
||||
let (f, overflow) = fmt.format_number(n, sys);
|
||||
if overflow { Err(CellError::Value) } else { Ok(f.text) }
|
||||
}
|
||||
|
||||
const CACHE_CODES: &[&str] = &[
|
||||
"0",
|
||||
"0.00",
|
||||
"#,##0.00",
|
||||
"#,##0.00;[Red](#,##0.00)",
|
||||
"$#,##0.00",
|
||||
"0%",
|
||||
"0.0%",
|
||||
"0.00E+00",
|
||||
"##0.0E+0",
|
||||
"General",
|
||||
"general",
|
||||
"checkbox",
|
||||
"@",
|
||||
"0;@",
|
||||
"0;-0;;@",
|
||||
"00000",
|
||||
"# ?/?",
|
||||
"# ??/16",
|
||||
"?/?",
|
||||
"m/d/yyyy",
|
||||
"yyyy-mm-dd hh:mm:ss",
|
||||
"dddd, mmmm d, yyyy",
|
||||
"h:mm AM/PM",
|
||||
"[h]:mm:ss",
|
||||
"mm:ss.000",
|
||||
"[Red]0;[Blue]-0",
|
||||
"[>100]0.0;[<5]0.000;0",
|
||||
"[<=9999999]###-####;(###) ###-####",
|
||||
"[$-409]d mmm yy",
|
||||
"\"abc\"0",
|
||||
"\\x0",
|
||||
"*-0",
|
||||
"_(* #,##0_)",
|
||||
"0.0.0",
|
||||
"[",
|
||||
"\"abc",
|
||||
"0;0;0;0;0",
|
||||
"#,##0.00;[Red",
|
||||
"[$-]0",
|
||||
" 0.00 ",
|
||||
"[Color99]0",
|
||||
"0.00_);(0.00)",
|
||||
"#,,\"M\"",
|
||||
"yyyy",
|
||||
"",
|
||||
];
|
||||
|
||||
fn cache_values() -> Vec<Value> {
|
||||
vec![
|
||||
Value::Number(0.0),
|
||||
Value::Number(1234.5678),
|
||||
Value::Number(-42.5),
|
||||
Value::Number(0.5),
|
||||
Value::Number(45000.75),
|
||||
Value::Number(1e15),
|
||||
Value::Number(1e-7),
|
||||
Value::Number(-0.0001),
|
||||
Value::Number(f64::INFINITY),
|
||||
Value::from("abc"),
|
||||
Value::from("12.5"),
|
||||
Value::Bool(true),
|
||||
Value::Empty,
|
||||
Value::Error(CellError::NA),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cached_gives_the_same_results_cold_and_warm() {
|
||||
assert!(CACHE_CODES.len() >= 40);
|
||||
parse_cache::clear();
|
||||
let values = cache_values();
|
||||
for sys in [DateSystem::D1900, DateSystem::D1904] {
|
||||
// Cold on the first system, warm on the second.
|
||||
for code in CACHE_CODES {
|
||||
let direct = NumberFormat::parse(code);
|
||||
let cached = NumberFormat::parse_cached(code);
|
||||
assert_eq!(cached.code(), direct.code(), "{code:?}");
|
||||
assert_eq!(
|
||||
(cached.kind(), cached.is_date(), cached.is_text(), cached.is_general()),
|
||||
(direct.kind(), direct.is_date(), direct.is_text(), direct.is_general())
|
||||
);
|
||||
for v in &values {
|
||||
let want = text_uncached(v, code, sys);
|
||||
assert_eq!(text_function(v, code, sys), want, "TEXT({v:?}, {code:?})");
|
||||
// Again, now certainly warm.
|
||||
assert_eq!(text_function(v, code, sys), want, "warm TEXT({v:?}, {code:?})");
|
||||
assert_eq!(format_value(v, &cached, sys), format_value(v, &direct, sys), "{v:?} {code:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cached_parses_each_code_once() {
|
||||
parse_cache::clear();
|
||||
let before = parse_cache::parses();
|
||||
for i in 0..10_000 {
|
||||
assert!(text_function(&Value::Number(f64::from(i) + 0.5), "0.00", S).is_ok());
|
||||
}
|
||||
assert_eq!(parse_cache::parses() - before, 1);
|
||||
|
||||
let before = parse_cache::parses();
|
||||
for i in 0..10_000 {
|
||||
let code = if i % 2 == 0 { "0.00" } else { "#,##0.0" };
|
||||
assert!(text_function(&Value::Number(f64::from(i)), code, S).is_ok());
|
||||
}
|
||||
// "0.00" is still cached from above: only the second code is new.
|
||||
assert_eq!(parse_cache::parses() - before, 1);
|
||||
|
||||
parse_cache::clear();
|
||||
let before = parse_cache::parses();
|
||||
for i in 0..10_000 {
|
||||
let code = if i % 2 == 0 { "0.00" } else { "#,##0.0" };
|
||||
assert!(text_function(&Value::Number(f64::from(i)), code, S).is_ok());
|
||||
}
|
||||
assert_eq!(parse_cache::parses() - before, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cache_is_bounded() {
|
||||
parse_cache::clear();
|
||||
// More distinct codes than the capacity: all still format correctly...
|
||||
let n = parse_cache::CAPACITY + 10;
|
||||
for i in 0..n {
|
||||
let c = format!("0.00\"{i}\"");
|
||||
assert_eq!(text_function(&Value::Number(1.0), &c, S), Ok(format!("1.00{i}")));
|
||||
}
|
||||
// ...the first ones were evicted (parsed again), the newest is still cached.
|
||||
let before = parse_cache::parses();
|
||||
let newest = format!("0.00\"{}\"", n - 1);
|
||||
assert!(text_function(&Value::Number(1.0), &newest, S).is_ok());
|
||||
assert_eq!(parse_cache::parses() - before, 0);
|
||||
assert!(text_function(&Value::Number(1.0), "0.00\"0\"", S).is_ok());
|
||||
assert_eq!(parse_cache::parses() - before, 1);
|
||||
|
||||
// A code too long (in bytes) for the cache is parsed on every call and not stored. Two-byte
|
||||
// characters keep it under the 255-character cap that TEXT() refuses past.
|
||||
let long = format!("0\"{}\"", "é".repeat(parse_cache::MAX_CODE_LEN / 2 + 8));
|
||||
let before = parse_cache::parses();
|
||||
for _ in 0..3 {
|
||||
assert!(text_function(&Value::Number(1.0), &long, S).is_ok());
|
||||
}
|
||||
assert_eq!(parse_cache::parses() - before, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fraction_denominator_with_65536_zeros_does_not_panic() {
|
||||
// `{:0>n$}` panics for a width above u16::MAX; the cap keeps this out of reach of `parse`,
|
||||
// so the renderer is driven with the uncapped parser.
|
||||
let code = format!("# ?/{}", "0".repeat(65_536));
|
||||
let fmt = NumberFormat::parse_uncapped(&code);
|
||||
let text = format_value(&Value::Number(0.5), &fmt, S).text;
|
||||
assert!(text.starts_with(" 1/"), "{text}");
|
||||
// And a denominator with fewer digits than zeros is still zero padded.
|
||||
assert_eq!(f("# ?/000", 0.5), " 1/002");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_codes_are_capped_at_255_characters() {
|
||||
let ok = format!("0{}", "0".repeat(254));
|
||||
let long = format!("0{}", "0".repeat(255));
|
||||
assert_eq!(ok.chars().count(), MAX_FORMAT_CODE_CHARS);
|
||||
assert!(text_function(&Value::Number(1.0), &ok, S).is_ok());
|
||||
assert_eq!(text_function(&Value::Number(1.0), &long, S), Err(CellError::Value));
|
||||
// Capped codes display as General.
|
||||
assert_eq!(f(&long, 0.5), "0.5");
|
||||
// Characters are counted, not bytes: 255 two-byte characters are fine, 256 are not.
|
||||
let multi_ok = "é".repeat(255);
|
||||
assert!(multi_ok.len() > 255);
|
||||
assert!(text_function(&Value::Number(1.0), &multi_ok, S).is_ok());
|
||||
assert_eq!(text_function(&Value::Number(1.0), &"é".repeat(256), S), Err(CellError::Value));
|
||||
// 65536 zeros through the public API: no panic, TEXT is #VALUE!.
|
||||
let huge = format!("# ?/{}", "0".repeat(65_536));
|
||||
assert_eq!(text_function(&Value::Number(0.5), &huge, S), Err(CellError::Value));
|
||||
assert_eq!(f(&huge, 0.5), "0.5");
|
||||
}
|
||||
|
||||
/// Parses and renders `code` on a worker thread; panics if that takes more than 20 s, so a quadratic
|
||||
/// parser fails the test instead of hanging CI. Not a benchmark: at this size an O(n^2) pass takes
|
||||
/// minutes and an O(n) pass milliseconds, so the margin is decades wide.
|
||||
fn finishes_in_time(code: String) -> String {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
// Uncapped: `parse` turns a code past 255 characters into General before the parser sees it.
|
||||
let _ = tx.send(format_value(&Value::Number(1234.5), &NumberFormat::parse_uncapped(&code), S).text);
|
||||
});
|
||||
rx.recv_timeout(std::time::Duration::from_secs(20)).expect("format code did not parse and render in time")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_codes_parse_in_linear_time() {
|
||||
let n = 1_000_000;
|
||||
// Unclosed brackets, a run of commas after a digit, a run of fraction bars.
|
||||
assert!(finishes_in_time("[".repeat(n)).starts_with('['));
|
||||
finishes_in_time(format!("0{}", ",".repeat(n)));
|
||||
finishes_in_time(format!("0{}", "/".repeat(n)));
|
||||
finishes_in_time(format!("0{}0", ",".repeat(n)));
|
||||
// Unclosed brackets mixed with semicolons: each is a literal and a section separator stays one.
|
||||
finishes_in_time("[;".repeat(n / 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unclosed_bracket_is_a_literal_in_both_passes() {
|
||||
// Sections still split after an unclosed `[`.
|
||||
assert_eq!(f("[0;0", 5.0), "[5");
|
||||
assert_eq!(f("0;[0", -5.0), "[5");
|
||||
assert_eq!(f("0;-0;[", 0.0), "[");
|
||||
assert_eq!(parse::parse_sections("0;[Red-0").len(), 2);
|
||||
assert_eq!(parse::parse_sections("[0;0").len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_brackets_still_hold_their_semicolons() {
|
||||
assert_eq!(f("[Red]0;[Blue]-0", -5.0), "-5");
|
||||
assert_eq!(f("[>=100]0.0;0", 150.0), "150.0");
|
||||
assert_eq!(f("[$-409]0.00", 2.5), "2.50");
|
||||
assert_eq!(f("[$€-2] #,##0", 1234.0), "€ 1,234");
|
||||
assert_eq!(parse::parse_sections("[Red]0;[Blue]0").len(), 2);
|
||||
assert_eq!(parse::parse_sections("[unknown;x]0;0").len(), 2);
|
||||
assert_eq!(f("0\\[0", 5.0), "0[5");
|
||||
assert_eq!(f("\"[\"0;0", 5.0), "[5");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
//! `TEXT(value, format)` against the results Excel cached in Apache POI's
|
||||
//! `test-data/spreadsheet/NumberFormatApproxTests.xlsx` (sheet `Tests`: B = format, C = value,
|
||||
//! A = `=TEXT(C, B)`). The workbook is not committed; its rows are copied here as data.
|
||||
|
||||
use gridcraft_core::{DateSystem, Value};
|
||||
use gridcraft_numfmt::text_function;
|
||||
|
||||
/// (format, value, Excel's result).
|
||||
// The last row's 3.14159 is the workbook's value, not an approximation of pi.
|
||||
#[allow(clippy::approx_constant)]
|
||||
const CASES: &[(&str, f64, &str)] = &[
|
||||
(r"|#,e-#|", 123456.789, r"|1e5|"),
|
||||
(r"|#e-#,|", 123456.789, r"|1e5|"),
|
||||
(r"|#%e-#|", 123456.789, r"|1%e5|"),
|
||||
(r"|#e-#%|", 123456.789, r"|1e5%|"),
|
||||
(r"|#|e-|#|", 123456.789, r"|1|e|5|"),
|
||||
(r"|#.#|e-|#|", 123456.789, r"|1.2|e|5|"),
|
||||
(r"|####.####|e-|#|", 123456.789, r"|12.3457|e|4|"),
|
||||
(r"|#######.####|e-|#|", 123456.789, r"|123456.789|e|0|"),
|
||||
(r"|#,######.####|e-|#|", 123456.789, r"|123,456.789|e|0|"),
|
||||
(r"|0|e-|0|", 123456.789, r"|1|e|5|"),
|
||||
(r"|0.0|e-|0|", 123456.789, r"|1.2|e|5|"),
|
||||
(r"|0000.0000|e-|0|", 123456.789, r"|0012.3457|e|4|"),
|
||||
(r"|0000000.0000|e-|0|", 123456.789, r"|0123456.7890|e|0|"),
|
||||
(r"|0,000000.0000|e-|0|", 123456.789, r"|0,123,456.7890|e|0|"),
|
||||
(r"|?|e-|?|", 123456.789, r"|1|e|5|"),
|
||||
(r"|?.?|e-|?|", 123456.789, r"|1.2|e|5|"),
|
||||
(r"|????.????|e-|?|", 123456.789, r"| 12.3457|e|4|"),
|
||||
(r"|???????.????|e-|?|", 123456.789, r"| 123456.789 |e|0|"),
|
||||
(r"|?,??????.????|e-|?|", 123456.789, r"| 123,456.789 |e|0|"),
|
||||
(r"|#,e+#|", 123456.789, r"|1e+5|"),
|
||||
(r"|#e+#,|", 123456.789, r"|1e+5|"),
|
||||
(r"|#%e+#|", 123456.789, r"|1%e+5|"),
|
||||
(r"|#e+#%|", 123456.789, r"|1e+5%|"),
|
||||
(r"|#|e+|#|", 123456.789, r"|1|e|+5|"),
|
||||
(r"|#.#|e+|#|", 123456.789, r"|1.2|e|+5|"),
|
||||
(r"|####.####|e+|#|", 123456.789, r"|12.3457|e|+4|"),
|
||||
(r"|#######.####|e+|#|", 123456.789, r"|123456.789|e|+0|"),
|
||||
(r"|#,######.####|e+|#|", 123456.789, r"|123,456.789|e|+0|"),
|
||||
(r"|0|e+|0|", 123456.789, r"|1|e|+5|"),
|
||||
(r"|0.0|e+|0|", 123456.789, r"|1.2|e|+5|"),
|
||||
(r"|0000.0000|e+|0|", 123456.789, r"|0012.3457|e|+4|"),
|
||||
(r"|0000000.0000|e+|0|", 123456.789, r"|0123456.7890|e|+0|"),
|
||||
(r"|0,000000.0000|e+|0|", 123456.789, r"|0,123,456.7890|e|+0|"),
|
||||
(r"|?|e+|?|", 123456.789, r"|1|e|+5|"),
|
||||
(r"|?.?|e+|?|", 123456.789, r"|1.2|e|+5|"),
|
||||
(r"|????.????|e+|?|", 123456.789, r"| 12.3457|e|+4|"),
|
||||
(r"|???????.????|e+|?|", 123456.789, r"| 123456.789 |e|+0|"),
|
||||
(r"|?,??????.????|e+|?|", 123456.789, r"| 123,456.789 |e|+0|"),
|
||||
(r"|#.#|E-|#|", 123456.789, r"|1.2|E|5|"),
|
||||
(r"|#.#|E+|#|", 123456.789, r"|1.2|E|+5|"),
|
||||
(r"|#,e-#|", 1.23456789e-05, r"|1e-5|"),
|
||||
(r"|#e-#,|", 1.23456789e-05, r"|1e-5|"),
|
||||
(r"|#%e-#|", 1.23456789e-05, r"|1%e-5|"),
|
||||
(r"|#e-#%|", 1.23456789e-05, r"|1e-5%|"),
|
||||
(r"|#|e-|#|", 1.23456789e-05, r"|1|e|-5|"),
|
||||
(r"|#.#|e-|#|", 1.23456789e-05, r"|1.2|e|-5|"),
|
||||
(r"|####.####|e-|#|", 1.23456789e-05, r"|1234.5679|e|-8|"),
|
||||
(r"|#######.####|e-|#|", 1.23456789e-05, r"|123.4568|e|-7|"),
|
||||
(r"|#,######.####|e-|#|", 1.23456789e-05, r"|123.4568|e|-7|"),
|
||||
(r"|0|e-|0|", 1.23456789e-05, r"|1|e|-5|"),
|
||||
(r"|0.0|e-|0|", 1.23456789e-05, r"|1.2|e|-5|"),
|
||||
(r"|0000.0000|e-|0|", 1.23456789e-05, r"|1234.5679|e|-8|"),
|
||||
(r"|0000000.0000|e-|0|", 1.23456789e-05, r"|0000123.4568|e|-7|"),
|
||||
(r"|0,000000.0000|e-|0|", 1.23456789e-05, r"|0,000,123.4568|e|-7|"),
|
||||
(r"|?|e-|?|", 1.23456789e-05, r"|1|e|-5|"),
|
||||
(r"|?.?|e-|?|", 1.23456789e-05, r"|1.2|e|-5|"),
|
||||
(r"|????.????|e-|?|", 1.23456789e-05, r"|1234.5679|e|-8|"),
|
||||
(r"|???????.????|e-|?|", 1.23456789e-05, r"| 123.4568|e|-7|"),
|
||||
(r"|?,??????.????|e-|?|", 1.23456789e-05, r"| 123.4568|e|-7|"),
|
||||
(r"|#,e+#|", 1.23456789e-05, r"|1e-5|"),
|
||||
(r"|#e+#,|", 1.23456789e-05, r"|1e-5|"),
|
||||
(r"|#%e+#|", 1.23456789e-05, r"|1%e-5|"),
|
||||
(r"|#e+#%|", 1.23456789e-05, r"|1e-5%|"),
|
||||
(r"|#|e+|#|", 1.23456789e-05, r"|1|e|-5|"),
|
||||
(r"|#.#|e+|#|", 1.23456789e-05, r"|1.2|e|-5|"),
|
||||
(r"|#.####|e+|#|", 0.000123456789, r"|1.2346|e|-4|"),
|
||||
(r"|##.####|e+|#|", 0.000123456789, r"|1.2346|e|-4|"),
|
||||
(r"|###.####|e+|#|", 0.000123456789, r"|123.4568|e|-6|"),
|
||||
(r"|####.####|e+|#|", 0.000123456789, r"|1.2346|e|-4|"),
|
||||
(r"|#####.####|e+|#|", 0.000123456789, r"|12.3457|e|-5|"),
|
||||
(r"|#######.####|e+|#|", 0.000123456789, r"|1234.5679|e|-7|"),
|
||||
(r"|#.####|e+|#|", 1.23456789e-05, r"|1.2346|e|-5|"),
|
||||
(r"|##.####|e+|#|", 1.23456789e-05, r"|12.3457|e|-6|"),
|
||||
(r"|###.####|e+|#|", 1.23456789e-05, r"|12.3457|e|-6|"),
|
||||
(r"|####.####|e+|#|", 1.23456789e-05, r"|1234.5679|e|-8|"),
|
||||
(r"|#####.####|e+|#|", 1.23456789e-05, r"|1.2346|e|-5|"),
|
||||
(r"|#######.####|e+|#|", 1.23456789e-05, r"|123.4568|e|-7|"),
|
||||
(r"|#.####|e+|#|", 1.23456789e-06, r"|1.2346|e|-6|"),
|
||||
(r"|##.####|e+|#|", 1.23456789e-06, r"|1.2346|e|-6|"),
|
||||
(r"|###.####|e+|#|", 1.23456789e-06, r"|1.2346|e|-6|"),
|
||||
(r"|####.####|e+|#|", 1.23456789e-06, r"|123.4568|e|-8|"),
|
||||
(r"|#####.####|e+|#|", 1.23456789e-06, r"|12345.6789|e|-10|"),
|
||||
(r"|#######.####|e+|#|", 1.23456789e-06, r"|12.3457|e|-7|"),
|
||||
(r"|#,######.####|e+|#|", 1.23456789e-06, r"|12.3457|e|-7|"),
|
||||
(r"|0|e+|0|", 1.23456789e-05, r"|1|e|-5|"),
|
||||
(r"|0.0|e+|0|", 1.23456789e-05, r"|1.2|e|-5|"),
|
||||
(r"|0000.0000|e+|0|", 1.23456789e-05, r"|1234.5679|e|-8|"),
|
||||
(r"|0000000.0000|e+|0|", 1.23456789e-05, r"|0000123.4568|e|-7|"),
|
||||
(r"|0,000000.0000|e+|0|", 1.23456789e-05, r"|0,000,123.4568|e|-7|"),
|
||||
(r"|?|e+|?|", 1.23456789e-05, r"|1|e|-5|"),
|
||||
(r"|?.?|e+|?|", 1.23456789e-05, r"|1.2|e|-5|"),
|
||||
(r"|????.????|e+|?|", 1.23456789e-05, r"|1234.5679|e|-8|"),
|
||||
(r"|???????.????|e+|?|", 1.23456789e-05, r"| 123.4568|e|-7|"),
|
||||
(r"|?,??????.????|e+|?|", 1.23456789e-05, r"| 123.4568|e|-7|"),
|
||||
(r"|#.#|E-|#|", 1.23456789e-05, r"|1.2|E|-5|"),
|
||||
(r"|#.#|E+|#|", 1.23456789e-05, r"|1.2|E|-5|"),
|
||||
(r"|#,e-#|", 0.0, r"|0e0|"),
|
||||
(r"|#e-#,|", 0.0, r"|0e0|"),
|
||||
(r"|#%e-#|", 0.0, r"|0%e0|"),
|
||||
(r"|#e-#%|", 0.0, r"|0e0%|"),
|
||||
(r"|#|e-|#|", 0.0, r"|0|e|0|"),
|
||||
(r"|#.#|e-|#|", 0.0, r"|0.|e|0|"),
|
||||
(r"|####.####|e-|#|", 0.0, r"|0000.|e|0|"),
|
||||
(r"|#######.####|e-|#|", 0.0, r"|0000000.|e|0|"),
|
||||
(r"|#,######.####|e-|#|", 0.0, r"|0,000,000.|e|0|"),
|
||||
(r"|0|e-|0|", 0.0, r"|0|e|0|"),
|
||||
(r"|0.0|e-|0|", 0.0, r"|0.0|e|0|"),
|
||||
(r"|0000.0000|e-|0|", 0.0, r"|0000.0000|e|0|"),
|
||||
(r"|0000000.0000|e-|0|", 0.0, r"|0000000.0000|e|0|"),
|
||||
(r"|0,000000.0000|e-|0|", 0.0, r"|0,000,000.0000|e|0|"),
|
||||
(r"|?|e-|?|", 0.0, r"|0|e|0|"),
|
||||
(r"|?.?|e-|?|", 0.0, r"|0. |e|0|"),
|
||||
(r"|????.????|e-|?|", 0.0, r"|0000. |e|0|"),
|
||||
(r"|???????.????|e-|?|", 0.0, r"|0000000. |e|0|"),
|
||||
(r"|?,??????.????|e-|?|", 0.0, r"|0,000,000. |e|0|"),
|
||||
(r"|#,e+#|", 0.0, r"|0e+0|"),
|
||||
(r"|#e+#,|", 0.0, r"|0e+0|"),
|
||||
(r"|#%e+#|", 0.0, r"|0%e+0|"),
|
||||
(r"|#e+#%|", 0.0, r"|0e+0%|"),
|
||||
(r"|#|e+|#|", 0.0, r"|0|e|+0|"),
|
||||
(r"|#.#|e+|#|", 0.0, r"|0.|e|+0|"),
|
||||
(r"|####.####|e+|#|", 0.0, r"|0000.|e|+0|"),
|
||||
(r"|#######.####|e+|#|", 0.0, r"|0000000.|e|+0|"),
|
||||
(r"|#,######.####|e+|#|", 0.0, r"|0,000,000.|e|+0|"),
|
||||
(r"|0|e+|0|", 0.0, r"|0|e|+0|"),
|
||||
(r"|0.0|e+|0|", 0.0, r"|0.0|e|+0|"),
|
||||
(r"|0000.0000|e+|0|", 0.0, r"|0000.0000|e|+0|"),
|
||||
(r"|0000000.0000|e+|0|", 0.0, r"|0000000.0000|e|+0|"),
|
||||
(r"|0,000000.0000|e+|0|", 0.0, r"|0,000,000.0000|e|+0|"),
|
||||
(r"|?|e+|?|", 0.0, r"|0|e|+0|"),
|
||||
(r"|?.?|e+|?|", 0.0, r"|0. |e|+0|"),
|
||||
(r"|????.????|e+|?|", 0.0, r"|0000. |e|+0|"),
|
||||
(r"|???????.????|e+|?|", 0.0, r"|0000000. |e|+0|"),
|
||||
(r"|?,??????.????|e+|?|", 0.0, r"|0,000,000. |e|+0|"),
|
||||
(r"|#.#|E-|#|", 0.0, r"|0.|E|0|"),
|
||||
(r"|#.#|E+|#|", 0.0, r"|0.|E|+0|"),
|
||||
(r"|#,e-#|", -123456.789, r"-|1e5|"),
|
||||
(r"|#e-#,|", -123456.789, r"-|1e5|"),
|
||||
(r"|#%e-#|", -123456.789, r"-|1%e5|"),
|
||||
(r"|#e-#%|", -123456.789, r"-|1e5%|"),
|
||||
(r"|#|e-|#|", -123456.789, r"-|1|e|5|"),
|
||||
(r"|#.#|e-|#|", -123456.789, r"-|1.2|e|5|"),
|
||||
(r"|####.####|e-|#|", -123456.789, r"-|12.3457|e|4|"),
|
||||
(r"|#######.####|e-|#|", -123456.789, r"-|123456.789|e|0|"),
|
||||
(r"|#,######.####|e-|#|", -123456.789, r"-|123,456.789|e|0|"),
|
||||
(r"|0|e-|0|", -123456.789, r"-|1|e|5|"),
|
||||
(r"|0.0|e-|0|", -123456.789, r"-|1.2|e|5|"),
|
||||
(r"|0000.0000|e-|0|", -123456.789, r"-|0012.3457|e|4|"),
|
||||
(r"|0000000.0000|e-|0|", -123456.789, r"-|0123456.7890|e|0|"),
|
||||
(r"|0,000000.0000|e-|0|", -123456.789, r"-|0,123,456.7890|e|0|"),
|
||||
(r"|?|e-|?|", -123456.789, r"-|1|e|5|"),
|
||||
(r"|?.?|e-|?|", -123456.789, r"-|1.2|e|5|"),
|
||||
(r"|????.????|e-|?|", -123456.789, r"-| 12.3457|e|4|"),
|
||||
(r"|???????.????|e-|?|", -123456.789, r"-| 123456.789 |e|0|"),
|
||||
(r"|?,??????.????|e-|?|", -123456.789, r"-| 123,456.789 |e|0|"),
|
||||
(r"|#,e+#|", -123456.789, r"-|1e+5|"),
|
||||
(r"|#e+#,|", -123456.789, r"-|1e+5|"),
|
||||
(r"|#%e+#|", -123456.789, r"-|1%e+5|"),
|
||||
(r"|#e+#%|", -123456.789, r"-|1e+5%|"),
|
||||
(r"|#|e+|#|", -123456.789, r"-|1|e|+5|"),
|
||||
(r"|#.#|e+|#|", -123456.789, r"-|1.2|e|+5|"),
|
||||
(r"|####.####|e+|#|", -123456.789, r"-|12.3457|e|+4|"),
|
||||
(r"|#######.####|e+|#|", -123456.789, r"-|123456.789|e|+0|"),
|
||||
(r"|#,######.####|e+|#|", -123456.789, r"-|123,456.789|e|+0|"),
|
||||
(r"|0|e+|0|", -123456.789, r"-|1|e|+5|"),
|
||||
(r"|0.0|e+|0|", -123456.789, r"-|1.2|e|+5|"),
|
||||
(r"|0000.0000|e+|0|", -123456.789, r"-|0012.3457|e|+4|"),
|
||||
(r"|0000000.0000|e+|0|", -123456.789, r"-|0123456.7890|e|+0|"),
|
||||
(r"|0,000000.0000|e+|0|", -123456.789, r"-|0,123,456.7890|e|+0|"),
|
||||
(r"|?|e+|?|", -123456.789, r"-|1|e|+5|"),
|
||||
(r"|?.?|e+|?|", -123456.789, r"-|1.2|e|+5|"),
|
||||
(r"|????.????|e+|?|", -123456.789, r"-| 12.3457|e|+4|"),
|
||||
(r"|???????.????|e+|?|", -123456.789, r"-| 123456.789 |e|+0|"),
|
||||
(r"|?,??????.????|e+|?|", -123456.789, r"-| 123,456.789 |e|+0|"),
|
||||
(r"|#.#|E-|#|", -123456.789, r"-|1.2|E|5|"),
|
||||
(r"|#.#|E+|#|", -123456.789, r"-|1.2|E|+5|"),
|
||||
(r"|#,e-#|", -1.23456789e-05, r"-|1e-5|"),
|
||||
(r"|#e-#,|", -1.23456789e-05, r"-|1e-5|"),
|
||||
(r"|#%e-#|", -1.23456789e-05, r"-|1%e-5|"),
|
||||
(r"|#e-#%|", -1.23456789e-05, r"-|1e-5%|"),
|
||||
(r"|#|e-|#|", -1.23456789e-05, r"-|1|e|-5|"),
|
||||
(r"|#.#|e-|#|", -1.23456789e-05, r"-|1.2|e|-5|"),
|
||||
(r"|####.####|e-|#|", -1.23456789e-05, r"-|1234.5679|e|-8|"),
|
||||
(r"|#######.####|e-|#|", -1.23456789e-05, r"-|123.4568|e|-7|"),
|
||||
(r"|#,######.####|e-|#|", -1.23456789e-05, r"-|123.4568|e|-7|"),
|
||||
(r"|0|e-|0|", -1.23456789e-05, r"-|1|e|-5|"),
|
||||
(r"|0.0|e-|0|", -1.23456789e-05, r"-|1.2|e|-5|"),
|
||||
(r"|0000.0000|e-|0|", -1.23456789e-05, r"-|1234.5679|e|-8|"),
|
||||
(r"|0000000.0000|e-|0|", -1.23456789e-05, r"-|0000123.4568|e|-7|"),
|
||||
(r"|0,000000.0000|e-|0|", -1.23456789e-05, r"-|0,000,123.4568|e|-7|"),
|
||||
(r"|?|e-|?|", -1.23456789e-05, r"-|1|e|-5|"),
|
||||
(r"|?.?|e-|?|", -1.23456789e-05, r"-|1.2|e|-5|"),
|
||||
(r"|????.????|e-|?|", -1.23456789e-05, r"-|1234.5679|e|-8|"),
|
||||
(r"|???????.????|e-|?|", -1.23456789e-05, r"-| 123.4568|e|-7|"),
|
||||
(r"|?,??????.????|e-|?|", -1.23456789e-05, r"-| 123.4568|e|-7|"),
|
||||
(r"|#,e+#|", -1.23456789e-05, r"-|1e-5|"),
|
||||
(r"|#e+#,|", -1.23456789e-05, r"-|1e-5|"),
|
||||
(r"|#%e+#|", -1.23456789e-05, r"-|1%e-5|"),
|
||||
(r"|#e+#%|", -1.23456789e-05, r"-|1e-5%|"),
|
||||
(r"|#|e+|#|", -1.23456789e-05, r"-|1|e|-5|"),
|
||||
(r"|#.#|e+|#|", -1.23456789e-05, r"-|1.2|e|-5|"),
|
||||
(r"|####.####|e+|#|", -1.23456789e-05, r"-|1234.5679|e|-8|"),
|
||||
(r"|#######.####|e+|#|", -1.23456789e-05, r"-|123.4568|e|-7|"),
|
||||
(r"|#,######.####|e+|#|", -1.23456789e-05, r"-|123.4568|e|-7|"),
|
||||
(r"|0|e+|0|", -1.23456789e-05, r"-|1|e|-5|"),
|
||||
(r"|0.0|e+|0|", -1.23456789e-05, r"-|1.2|e|-5|"),
|
||||
(r"|0000.0000|e+|0|", -1.23456789e-05, r"-|1234.5679|e|-8|"),
|
||||
(r"|0000000.0000|e+|0|", -1.23456789e-05, r"-|0000123.4568|e|-7|"),
|
||||
(r"|0,000000.0000|e+|0|", -1.23456789e-05, r"-|0,000,123.4568|e|-7|"),
|
||||
(r"|?|e+|?|", -1.23456789e-05, r"-|1|e|-5|"),
|
||||
(r"|?.?|e+|?|", -1.23456789e-05, r"-|1.2|e|-5|"),
|
||||
(r"|????.????|e+|?|", -1.23456789e-05, r"-|1234.5679|e|-8|"),
|
||||
(r"|???????.????|e+|?|", -1.23456789e-05, r"-| 123.4568|e|-7|"),
|
||||
(r"|?,??????.????|e+|?|", -1.23456789e-05, r"-| 123.4568|e|-7|"),
|
||||
(r"|#.#|E-|#|", -1.23456789e-05, r"-|1.2|E|-5|"),
|
||||
(r"|#.#|E+|#|", -1.23456789e-05, r"-|1.2|E|-5|"),
|
||||
(r"|#.#|e+|#|", 1.2345678e+142, r"|1.2|e|+142|"),
|
||||
(r"|#.#|e+|#####|", 1.2345678e+142, r"|1.2|e|+142|"),
|
||||
(r"|#.#|e+|0|", 1.2345678e+142, r"|1.2|e|+142|"),
|
||||
(r"|#.#|e+|000|", 1.2345678e+142, r"|1.2|e|+142|"),
|
||||
(r"|#.#|e+|0000|", 1.2345678e+142, r"|1.2|e|+0142|"),
|
||||
(r"|#.#|e+|00000|", 1.2345678e+142, r"|1.2|e|+00142|"),
|
||||
(r"|#.#|e+|?|", 1.2345678e+142, r"|1.2|e|+142|"),
|
||||
(r"|#.#|e+|???|", 1.2345678e+142, r"|1.2|e|+142|"),
|
||||
(r"|#.#|e+|????|", 1.2345678e+142, r"|1.2|e|+ 142|"),
|
||||
(r"|#.#|e+|?????|", 1.2345678e+142, r"|1.2|e|+ 142|"),
|
||||
(r"|#.#|e+|#|", 0.0, r"|0.|e|+0|"),
|
||||
(r"|#.#|e+|#####|", 0.0, r"|0.|e|+0|"),
|
||||
(r"|#.#|e+|0|", 0.0, r"|0.|e|+0|"),
|
||||
(r"|#.#|e+|000|", 0.0, r"|0.|e|+000|"),
|
||||
(r"|#.#|e+|0000|", 0.0, r"|0.|e|+0000|"),
|
||||
(r"|#.#|e+|00000|", 0.0, r"|0.|e|+00000|"),
|
||||
(r"|#.#|e+|?|", 0.0, r"|0.|e|+0|"),
|
||||
(r"|#.#|e+|???|", 0.0, r"|0.|e|+ 0|"),
|
||||
(r"|#.#|e+|????|", 0.0, r"|0.|e|+ 0|"),
|
||||
(r"|#.#|e+|?????|", 0.0, r"|0.|e|+ 0|"),
|
||||
(r"|#.#|e+|#|", 1.2345678e-142, r"|1.2|e|-142|"),
|
||||
(r"|#.#|e+|#####|", 1.2345678e-142, r"|1.2|e|-142|"),
|
||||
(r"|#.#|e+|0|", 1.2345678e-142, r"|1.2|e|-142|"),
|
||||
(r"|#.#|e+|000|", 1.2345678e-142, r"|1.2|e|-142|"),
|
||||
(r"|#.#|e+|0000|", 1.2345678e-142, r"|1.2|e|-0142|"),
|
||||
(r"|#.#|e+|00000|", 1.2345678e-142, r"|1.2|e|-00142|"),
|
||||
(r"|#.#|e+|000,00|", 1.2345678e-142, r"|1.2|e|-00142|"),
|
||||
(r"|#.#|e+|?|", 1.2345678e-142, r"|1.2|e|-142|"),
|
||||
(r"|#.#|e+|???|", 1.2345678e-142, r"|1.2|e|-142|"),
|
||||
(r"|#.#|e+|????|", 1.2345678e-142, r"|1.2|e|- 142|"),
|
||||
(r"|#.#|e+|?????|", 1.2345678e-142, r"|1.2|e|- 142|"),
|
||||
(r"#,##0.00e+00", 3.14159, r"3.14e+00"),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn poi_number_format_approx_tests() {
|
||||
let wrong: Vec<String> = CASES
|
||||
.iter()
|
||||
.filter_map(|&(code, v, want)| {
|
||||
let got = text_function(&Value::Number(v), code, DateSystem::D1900);
|
||||
(got.as_deref() != Ok(want)).then(|| format!("TEXT({v}, {code:?}) = {got:?}, Excel {want:?}"))
|
||||
})
|
||||
.collect();
|
||||
assert!(wrong.is_empty(), "{} of {} cases differ:\n{}", wrong.len(), CASES.len(), wrong.join("\n"));
|
||||
}
|
||||
+71
-17
@@ -145,7 +145,8 @@ impl Font {
|
||||
|
||||
/// Width of `text` in points when set in `font` at `size` points.
|
||||
pub fn text_width(font: Font, size: f32, text: &str) -> f32 {
|
||||
let units: u32 = text.chars().map(|c| font.char_width(c) as u32).sum();
|
||||
// u64: a u32 sum overflows for ~8 million characters.
|
||||
let units: u64 = text.chars().map(|c| u64::from(font.char_width(c))).sum();
|
||||
units as f32 * size / 1000.0
|
||||
}
|
||||
|
||||
@@ -221,6 +222,32 @@ fn n(v: f32) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// True when every value is finite (NaN and infinities are rejected).
|
||||
fn finite(v: &[f32]) -> bool {
|
||||
v.iter().all(|x| x.is_finite())
|
||||
}
|
||||
|
||||
/// A positive, finite extent (a width, height or radius).
|
||||
fn extent(v: f32) -> bool {
|
||||
v > 0.0 && v.is_finite()
|
||||
}
|
||||
|
||||
/// A stroke width: negative, NaN and infinite become 0 (the thinnest line).
|
||||
fn stroke_width(w: f32) -> f32 {
|
||||
if w.is_finite() { w.max(0.0) } else { 0.0 }
|
||||
}
|
||||
|
||||
/// The entries of a dash array (`[on, off, …]`, at most 8). A pattern the PDF specification
|
||||
/// forbids (any non-finite entry, or no entry that survives rounding to a positive number) is
|
||||
/// replaced by an empty array, i.e. a solid line.
|
||||
fn dash_values(dash: &[f32]) -> String {
|
||||
let d: Vec<f32> = dash.iter().take(8).map(|v| if v.is_finite() { v.max(0.0) } else { f32::NAN }).collect();
|
||||
if !finite(&d) || !d.iter().any(|v| (v.min(1.0e7) * 1000.0).round() > 0.0) {
|
||||
return String::new();
|
||||
}
|
||||
d.iter().map(|v| n(*v)).collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
fn rgb_op(c: Rgb, stroke: bool) -> String {
|
||||
let f = |v: u8| n(v as f32 / 255.0);
|
||||
format!("{} {} {} {}", f(c[0]), f(c[1]), f(c[2]), if stroke { "RG" } else { "rg" })
|
||||
@@ -288,7 +315,7 @@ impl Page {
|
||||
}
|
||||
|
||||
pub fn fill_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: Rgb) {
|
||||
if w <= 0.0 || h <= 0.0 {
|
||||
if !(extent(w) && extent(h) && finite(&[x, y])) {
|
||||
return;
|
||||
}
|
||||
let s = format!("{}\n{} {} {} {} re f", rgb_op(color, false), n(x), n(self.y(y + h)), n(w), n(h));
|
||||
@@ -296,7 +323,10 @@ impl Page {
|
||||
}
|
||||
|
||||
pub fn rect_stroke(&mut self, x: f32, y: f32, w: f32, h: f32, width: f32, color: Rgb) {
|
||||
let s = format!("{}\n{} w [] 0 d\n{} {} {} {} re S", rgb_op(color, true), n(width), n(x), n(self.y(y + h)), n(w), n(h));
|
||||
if !finite(&[x, y, w, h]) {
|
||||
return;
|
||||
}
|
||||
let s = format!("{}\n{} w [] 0 d\n{} {} {} {} re S", rgb_op(color, true), n(stroke_width(width)), n(x), n(self.y(y + h)), n(w), n(h));
|
||||
self.op(&s);
|
||||
}
|
||||
|
||||
@@ -306,19 +336,28 @@ impl Page {
|
||||
|
||||
/// A line with a dash pattern (`[on, off, …]` in points; empty = solid).
|
||||
pub fn stroke_line_dashed(&mut self, x0: f32, y0: f32, x1: f32, y1: f32, width: f32, color: Rgb, dash: &[f32]) {
|
||||
let d: Vec<String> = dash.iter().take(8).map(|v| n(v.max(0.0))).collect();
|
||||
let s =
|
||||
format!("{}\n{} w [{}] 0 d\n{} {} m {} {} l S", rgb_op(color, true), n(width), d.join(" "), n(x0), n(self.y(y0)), n(x1), n(self.y(y1)));
|
||||
if !finite(&[x0, y0, x1, y1]) {
|
||||
return;
|
||||
}
|
||||
let s = format!(
|
||||
"{}\n{} w [{}] 0 d\n{} {} m {} {} l S",
|
||||
rgb_op(color, true),
|
||||
n(stroke_width(width)),
|
||||
dash_values(dash),
|
||||
n(x0),
|
||||
n(self.y(y0)),
|
||||
n(x1),
|
||||
n(self.y(y1))
|
||||
);
|
||||
self.op(&s);
|
||||
}
|
||||
|
||||
/// An open polyline.
|
||||
pub fn polyline(&mut self, pts: &[[f32; 2]], width: f32, color: Rgb, dash: &[f32]) {
|
||||
if pts.len() < 2 {
|
||||
if pts.len() < 2 || !pts.iter().all(|p| finite(p)) {
|
||||
return;
|
||||
}
|
||||
let d: Vec<String> = dash.iter().take(8).map(|v| n(v.max(0.0))).collect();
|
||||
let mut s = format!("{}\n{} w [{}] 0 d 1 j\n", rgb_op(color, true), n(width), d.join(" "));
|
||||
let mut s = format!("{}\n{} w [{}] 0 d 1 j\n", rgb_op(color, true), n(stroke_width(width)), dash_values(dash));
|
||||
self.path(&mut s, pts, false);
|
||||
s.push_str(" S");
|
||||
self.op(&s);
|
||||
@@ -335,7 +374,7 @@ impl Page {
|
||||
|
||||
/// A closed polygon, filled and/or stroked.
|
||||
pub fn polygon(&mut self, pts: &[[f32; 2]], fill: Option<Rgb>, stroke: Option<(Rgb, f32)>) {
|
||||
if pts.len() < 2 || (fill.is_none() && stroke.is_none()) {
|
||||
if pts.len() < 2 || (fill.is_none() && stroke.is_none()) || !pts.iter().all(|p| finite(p)) {
|
||||
return;
|
||||
}
|
||||
let mut s = String::new();
|
||||
@@ -344,7 +383,7 @@ impl Page {
|
||||
s.push('\n');
|
||||
}
|
||||
if let Some((c, w)) = stroke {
|
||||
let _ = writeln!(s, "{}\n{} w [] 0 d 1 j", rgb_op(c, true), n(w));
|
||||
let _ = writeln!(s, "{}\n{} w [] 0 d 1 j", rgb_op(c, true), n(stroke_width(w)));
|
||||
}
|
||||
self.path(&mut s, pts, true);
|
||||
s.push_str(match (fill.is_some(), stroke.is_some()) {
|
||||
@@ -357,7 +396,7 @@ impl Page {
|
||||
|
||||
/// A circle approximated by four cubic Béziers.
|
||||
pub fn circle(&mut self, cx: f32, cy: f32, r: f32, fill: Option<Rgb>, stroke: Option<(Rgb, f32)>) {
|
||||
if r <= 0.0 || (fill.is_none() && stroke.is_none()) {
|
||||
if !extent(r) || !finite(&[cx, cy]) || (fill.is_none() && stroke.is_none()) {
|
||||
return;
|
||||
}
|
||||
const K: f32 = 0.552_284_8;
|
||||
@@ -369,7 +408,7 @@ impl Page {
|
||||
s.push('\n');
|
||||
}
|
||||
if let Some((c, w)) = stroke {
|
||||
let _ = writeln!(s, "{}\n{} w [] 0 d", rgb_op(c, true), n(w));
|
||||
let _ = writeln!(s, "{}\n{} w [] 0 d", rgb_op(c, true), n(stroke_width(w)));
|
||||
}
|
||||
let _ = write!(
|
||||
s,
|
||||
@@ -417,7 +456,7 @@ impl Page {
|
||||
/// Text rotated by `angle` radians (clockwise on the page, like the y-down coordinate
|
||||
/// system) about its baseline origin.
|
||||
pub fn text_rotated(&mut self, x: f32, y: f32, size: f32, font: Font, color: Rgb, text: &str, angle: f32) {
|
||||
if text.is_empty() || !(size.is_finite() && size > 0.0) {
|
||||
if text.is_empty() || !extent(size) || !finite(&[x, y]) {
|
||||
return;
|
||||
}
|
||||
let fi = font.index();
|
||||
@@ -446,7 +485,7 @@ impl Page {
|
||||
/// `(x, y, w, h)`. Malformed or oversized buffers are ignored.
|
||||
pub fn image_rgba(&mut self, x: f32, y: f32, w: f32, h: f32, rgba: &[u8], pw: u32, ph: u32) {
|
||||
let px = pw as u64 * ph as u64;
|
||||
if pw == 0 || ph == 0 || px > MAX_IMAGE_PIXELS || (rgba.len() as u64) < px * 4 || w <= 0.0 || h <= 0.0 {
|
||||
if pw == 0 || ph == 0 || px > MAX_IMAGE_PIXELS || (rgba.len() as u64) < px * 4 || !(extent(w) && extent(h) && finite(&[x, y])) {
|
||||
return;
|
||||
}
|
||||
let px = px as usize;
|
||||
@@ -516,8 +555,23 @@ fn run_length(data: &[u8]) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
fn pdf_text_string(s: &str) -> String {
|
||||
pdf_string(s)
|
||||
/// Longest Info string written, in characters; longer values are cut on a character boundary.
|
||||
const MAX_INFO_CHARS: usize = 1024;
|
||||
|
||||
/// A PDF text string (document Info entries) for `text`: a literal `( … )` for printable ASCII,
|
||||
/// otherwise UTF-16BE with a byte-order mark as a hex string (ISO 32000-1 7.9.2.2).
|
||||
fn pdf_text_string(text: &str) -> String {
|
||||
let text: String = text.chars().take(MAX_INFO_CHARS).collect();
|
||||
if text.chars().all(|c| matches!(c, ' '..='~')) {
|
||||
return pdf_string(&text);
|
||||
}
|
||||
let mut s = String::with_capacity(text.len() * 4 + 6);
|
||||
s.push_str("<FEFF");
|
||||
for unit in text.encode_utf16() {
|
||||
let _ = write!(s, "{unit:04X}");
|
||||
}
|
||||
s.push('>');
|
||||
s
|
||||
}
|
||||
|
||||
impl PdfDoc {
|
||||
|
||||
@@ -157,3 +157,123 @@ fn hostile_inputs_never_panic() {
|
||||
let pdf = d.finish();
|
||||
check_xref(&pdf);
|
||||
}
|
||||
|
||||
fn page_content(d: &PdfDoc) -> &str {
|
||||
&d.pages[0].content
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_width_does_not_overflow() {
|
||||
// 8,000,000 x 556 units exceeds u32::MAX: a u32 sum panics in debug builds.
|
||||
let long = "a".repeat(8_000_000);
|
||||
let w = text_width(Font::Helvetica, 10.0, &long);
|
||||
assert!(w.is_finite() && w > 0.0);
|
||||
assert!(w > text_width(Font::Helvetica, 10.0, &"a".repeat(7_000_000)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_zero_dash_is_drawn_solid() {
|
||||
let mut d = PdfDoc::new();
|
||||
let p = d.add_page(100.0, 100.0);
|
||||
p.stroke_line_dashed(0.0, 0.0, 50.0, 0.0, 1.0, [0, 0, 0], &[0.0, 0.0]);
|
||||
p.polyline(&[[0.0, 0.0], [5.0, 5.0]], 1.0, [0, 0, 0], &[0.0, 0.0001, f32::NAN]);
|
||||
p.stroke_line_dashed(0.0, 0.0, 50.0, 0.0, 1.0, [0, 0, 0], &[3.0, 2.0]);
|
||||
let c = page_content(&d);
|
||||
assert_eq!(c.matches("[] 0 d").count(), 2);
|
||||
assert!(!c.contains("[0 0]"));
|
||||
assert!(c.contains("[3 2] 0 d"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_and_nan_stroke_widths_become_zero() {
|
||||
let mut d = PdfDoc::new();
|
||||
let p = d.add_page(100.0, 100.0);
|
||||
p.rect_stroke(1.0, 1.0, 10.0, 10.0, -3.0, [0, 0, 0]);
|
||||
p.stroke_line(0.0, 0.0, 5.0, 5.0, f32::NAN, [0, 0, 0]);
|
||||
p.polygon(&[[0.0, 0.0], [5.0, 0.0], [5.0, 5.0]], None, Some(([0, 0, 0], -1.0)));
|
||||
p.circle(20.0, 20.0, 5.0, None, Some(([0, 0, 0], f32::NEG_INFINITY)));
|
||||
let c = page_content(&d);
|
||||
assert_eq!(c.matches("\n0 w ").count(), 4);
|
||||
assert!(!c.contains("-1 w") && !c.contains("-3 w"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_finite_image_size_stores_nothing() {
|
||||
let mut d = PdfDoc::new();
|
||||
let p = d.add_page(200.0, 200.0);
|
||||
let px = [255u8; 16];
|
||||
for (w, h) in [(f32::NAN, 10.0), (10.0, f32::NAN), (f32::INFINITY, 10.0), (-1.0, 10.0)] {
|
||||
p.image_rgba(0.0, 0.0, w, h, &px, 2, 2);
|
||||
}
|
||||
p.image_rgba(f32::NAN, 0.0, 10.0, 10.0, &px, 2, 2);
|
||||
assert!(p.images.is_empty());
|
||||
assert!(p.content.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_draw_method_survives_hostile_numbers() {
|
||||
let bad = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -7.0, 0.0, 1.0e30, -1.0e30];
|
||||
let px = [200u8; 16];
|
||||
let mut d = PdfDoc::new();
|
||||
let p = d.add_page(300.0, 300.0);
|
||||
for &a in &bad {
|
||||
for &b in &bad {
|
||||
p.clip_rect(a, b, a, b);
|
||||
p.fill_rect(a, b, b, a, [1, 2, 3]);
|
||||
p.rect_stroke(a, b, a, b, a, [1, 2, 3]);
|
||||
p.stroke_line(a, b, b, a, a, [1, 2, 3]);
|
||||
p.stroke_line_dashed(a, b, b, a, b, [1, 2, 3], &[a, b, 0.0]);
|
||||
p.polyline(&[[a, b], [b, a], [1.0, 1.0]], a, [1, 2, 3], &[b, a]);
|
||||
p.polygon(&[[a, b], [b, a], [1.0, 1.0]], Some([1, 2, 3]), Some(([4, 5, 6], a)));
|
||||
p.circle(a, b, b, Some([1, 2, 3]), Some(([4, 5, 6], a)));
|
||||
p.text(a, b, a, Font::Helvetica, [0, 0, 0], "x");
|
||||
p.text_rotated(a, b, 10.0, Font::TimesRoman, [0, 0, 0], "y", b);
|
||||
p.image_rgba(a, b, b, a, &px, 2, 2);
|
||||
}
|
||||
}
|
||||
p.stroke_line_dashed(0.0, 0.0, 1.0, 1.0, 1.0, [0, 0, 0], &[0.0; 100]);
|
||||
let c = page_content(&d);
|
||||
assert!(!c.contains("NaN") && !c.contains("inf"));
|
||||
let pdf = d.finish();
|
||||
check_xref(&pdf);
|
||||
}
|
||||
|
||||
/// Decodes the `<FEFF…>` hex string following `key` in the file bytes.
|
||||
fn utf16_entry(pdf: &[u8], key: &str) -> String {
|
||||
let at = find(pdf, key.as_bytes()).unwrap() + key.len();
|
||||
let rest = std::str::from_utf8(&pdf[at..at + 9000.min(pdf.len() - at)]).unwrap_or("");
|
||||
let hex = rest.trim_start().strip_prefix("<FEFF").unwrap();
|
||||
let hex = &hex[..hex.find('>').unwrap()];
|
||||
let units: Vec<u16> = (0..hex.len()).step_by(4).map(|i| u16::from_str_radix(&hex[i..i + 4], 16).unwrap()).collect();
|
||||
String::from_utf16(&units).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_strings_with_non_ascii_are_utf16be() {
|
||||
let mut doc = PdfDoc::new();
|
||||
doc.add_page(200.0, 200.0);
|
||||
doc.info.title = "Übersicht €5 – “Q1” 日本 😀".into();
|
||||
doc.info.author = "Müller & Søn".into();
|
||||
let pdf = doc.finish();
|
||||
assert_eq!(utf16_entry(&pdf, "/Title"), "Übersicht €5 – “Q1” 日本 😀");
|
||||
assert_eq!(utf16_entry(&pdf, "/Author"), "Müller & Søn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_info_strings_stay_literal_and_escaped() {
|
||||
let mut doc = PdfDoc::new();
|
||||
doc.add_page(200.0, 200.0);
|
||||
doc.info.title = r"Q1 (draft) a\b".into();
|
||||
let pdf = doc.finish();
|
||||
assert!(find(&pdf, br"/Title (Q1 \(draft\) a\\b)").is_some());
|
||||
assert!(find(&pdf, b"/Producer (GridCraft PDF)").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_strings_are_capped() {
|
||||
let mut doc = PdfDoc::new();
|
||||
doc.add_page(200.0, 200.0);
|
||||
doc.info.title = "日".repeat(100_000);
|
||||
let pdf = doc.finish();
|
||||
assert_eq!(utf16_entry(&pdf, "/Title").chars().count(), MAX_INFO_CHARS);
|
||||
}
|
||||
|
||||
+228
-14
@@ -4,8 +4,13 @@
|
||||
//! A navigation column on the left picks a page (Home, New, Open, Info, Save As, Print, Export,
|
||||
//! Options); Save and Close act at once. Every action runs an existing command through
|
||||
//! [`SheetApp::run`] or an existing dialog through [`SheetApp::open_dialog`]; the state here is
|
||||
//! UI-only and never saved. Recent workbooks are listed by path only: nothing on disk is touched
|
||||
//! until the user picks one.
|
||||
//! UI-only and never saved. Recent workbooks are listed by path, with when each was last changed:
|
||||
//! that is asked of the file system on a thread of its own, never while drawing, and only the
|
||||
//! files' metadata is read. No workbook is opened until the user picks one.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
|
||||
use egui::{Align2, Color32, Key, Modifiers, Rect, RichText, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
use serde_json::{Value as Json, json};
|
||||
@@ -109,6 +114,8 @@ pub struct Backstage {
|
||||
preview: Option<((u64, u64, bool, bool), Json)>,
|
||||
/// Save As: the chosen file type (an extension from [`SAVE_TYPES`]).
|
||||
pub save_format: &'static str,
|
||||
/// Home and Open: when the recent workbooks were last changed.
|
||||
recent: RecentFiles,
|
||||
}
|
||||
|
||||
/// Save As file types: (extension, label). The first is the workbook itself; the others are
|
||||
@@ -118,7 +125,16 @@ pub const SAVE_TYPES: [(&str, &str); 4] =
|
||||
|
||||
impl Backstage {
|
||||
pub fn new(page: Page) -> Backstage {
|
||||
Backstage { page, props: Default::default(), print_all: false, fit_to_page: false, preview_page: 0, preview: None, save_format: "xlsx" }
|
||||
Backstage {
|
||||
page,
|
||||
props: Default::default(),
|
||||
print_all: false,
|
||||
fit_to_page: false,
|
||||
preview_page: 0,
|
||||
preview: None,
|
||||
save_format: "xlsx",
|
||||
recent: RecentFiles::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,9 +203,9 @@ pub fn show(app: &mut SheetApp, ui: &mut Ui) {
|
||||
ui.label(RichText::new(lang.tr(bs.page.label())).font(theme::ui_font(28.0)).color(t.text));
|
||||
ui.add_space(18.0);
|
||||
match bs.page {
|
||||
Page::Home => home(app, ui, &mut out),
|
||||
Page::Home => home(app, ui, &mut bs, &mut out),
|
||||
Page::New => new_page(app, ui, &mut out),
|
||||
Page::Open => open_page(app, ui, &mut out),
|
||||
Page::Open => open_page(app, ui, &mut bs, &mut out),
|
||||
Page::Info => info(app, ui, &mut bs),
|
||||
Page::SaveAs => save_as(app, ui, &mut bs, &mut out),
|
||||
Page::Print => print(app, ui, &mut bs, &mut out),
|
||||
@@ -325,7 +341,7 @@ fn new_tiles(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) {
|
||||
});
|
||||
}
|
||||
|
||||
/// File name and folder of a recent path, split as text (nothing on disk is read).
|
||||
/// File name and folder of a recent path, split as text.
|
||||
fn split_path(path: &str) -> (String, String) {
|
||||
let p = std::path::Path::new(path);
|
||||
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or(path).to_string();
|
||||
@@ -333,8 +349,12 @@ fn split_path(path: &str) -> (String, String) {
|
||||
(name, folder)
|
||||
}
|
||||
|
||||
/// Recent workbooks, newest first. Opening one is the user's explicit choice.
|
||||
fn recent_list(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome, limit: usize) {
|
||||
/// Width of the column that tells when a recent workbook was last changed.
|
||||
const MODIFIED_W: f32 = 150.0;
|
||||
|
||||
/// Recent workbooks, newest first, each with when it was last changed. Opening one is the user's
|
||||
/// explicit choice.
|
||||
fn recent_list(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome, limit: usize) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let lang = app.ui.language;
|
||||
if app.ui.recent.is_empty() {
|
||||
@@ -342,6 +362,21 @@ fn recent_list(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome, limit: usize)
|
||||
return;
|
||||
}
|
||||
let width = ui.available_width().min(CONTENT_W);
|
||||
// What the file system has said so far; the rest arrives in later frames (see `RecentFiles`).
|
||||
let known = bs.recent.known(ui.ctx(), &app.ui.recent);
|
||||
// The web build has no files to ask about, and a narrow window leaves the names their room.
|
||||
let modified_w = if cfg!(target_arch = "wasm32") || width < 420.0 { 0.0 } else { MODIFIED_W };
|
||||
if modified_w > 0.0 {
|
||||
let (head, _) = ui.allocate_exact_size(vec2(width, 20.0), Sense::hover());
|
||||
ui.painter().text(
|
||||
pos2(head.right() - modified_w, head.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
lang.tr("Modified"),
|
||||
theme::ui_font(11.5),
|
||||
t.text_dim,
|
||||
);
|
||||
}
|
||||
let now = crate::now_ms() / 1000.0;
|
||||
for path in app.ui.recent.iter().take(limit).cloned().collect::<Vec<_>>() {
|
||||
let (name, folder) = split_path(&path);
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(width, 44.0), Sense::click());
|
||||
@@ -351,8 +386,19 @@ fn recent_list(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome, limit: usize)
|
||||
p.rect_filled(rect, 4.0, t.hover);
|
||||
}
|
||||
icons::paint(p, Rect::from_center_size(pos2(rect.left() + 20.0, rect.center().y), vec2(20.0, 20.0)), Icon::Book, t.text);
|
||||
p.text(pos2(rect.left() + 42.0, rect.center().y - 8.0), Align2::LEFT_CENTER, &name, theme::ui_font(13.5), t.text);
|
||||
p.text(pos2(rect.left() + 42.0, rect.center().y + 9.0), Align2::LEFT_CENTER, &folder, theme::ui_font(11.5), t.text_dim);
|
||||
// A long name or folder stops short of the column instead of running under it.
|
||||
let room = if modified_w > 0.0 { rect.right() - modified_w - 14.0 } else { rect.right() };
|
||||
let text = p.with_clip_rect(Rect::from_min_max(rect.min, pos2(room, rect.bottom())).intersect(p.clip_rect()));
|
||||
text.text(pos2(rect.left() + 42.0, rect.center().y - 8.0), Align2::LEFT_CENTER, &name, theme::ui_font(13.5), t.text);
|
||||
text.text(pos2(rect.left() + 42.0, rect.center().y + 9.0), Align2::LEFT_CENTER, &folder, theme::ui_font(11.5), t.text_dim);
|
||||
if modified_w > 0.0 {
|
||||
let (words, color) = match known.get(&path) {
|
||||
Some(Modified::At(seconds)) => (age(lang, now - seconds), t.text_dim),
|
||||
Some(Modified::Unknown) => (lang.tr("Not available").to_string(), t.text_disabled),
|
||||
None => (String::new(), t.text_dim),
|
||||
};
|
||||
p.text(pos2(rect.right() - modified_w, rect.center().y), Align2::LEFT_CENTER, words, theme::ui_font(12.0), color);
|
||||
}
|
||||
let resp = resp.on_hover_text(&path);
|
||||
if resp.clicked() {
|
||||
app.open_path(&path);
|
||||
@@ -363,6 +409,98 @@ fn recent_list(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome, limit: usize)
|
||||
}
|
||||
}
|
||||
|
||||
/// What the file system said about a recent workbook.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Modified {
|
||||
/// When it was last changed, in seconds since 1970.
|
||||
At(f64),
|
||||
/// It can't be reached: moved, deleted, or on a drive that is away.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// One thread at a time asks about recent workbooks, so a drive that never answers holds up one
|
||||
/// thread, not one for every time the view is opened.
|
||||
static ASKING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// When the listed workbooks were last changed, as far as the file system has answered.
|
||||
///
|
||||
/// Asking can take long (a network drive that is asleep, a share that is away), so it is never
|
||||
/// done while drawing: a thread asks, and the list shows each answer as it comes back. Only the
|
||||
/// files' metadata is read. A view opened anew asks anew.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct RecentFiles {
|
||||
/// The list the answers are for.
|
||||
asked: Vec<String>,
|
||||
found: Arc<Mutex<HashMap<String, Modified>>>,
|
||||
}
|
||||
|
||||
impl RecentFiles {
|
||||
/// What is known this frame about the files in `recent`; starts asking when the list is new.
|
||||
fn known(&mut self, ctx: &egui::Context, recent: &[String]) -> HashMap<String, Modified> {
|
||||
if self.asked != recent && !ASKING.swap(true, Ordering::SeqCst) {
|
||||
self.asked = recent.to_vec();
|
||||
if !ask(ctx.clone(), recent.to_vec(), self.found.clone()) {
|
||||
ASKING.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
self.found.lock().unwrap_or_else(PoisonError::into_inner).clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts the thread that asks. Returns whether it started.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn ask(ctx: egui::Context, paths: Vec<String>, found: Arc<Mutex<HashMap<String, Modified>>>) -> bool {
|
||||
/// Lets the next list ask, however the thread ends.
|
||||
struct Done;
|
||||
impl Drop for Done {
|
||||
fn drop(&mut self) {
|
||||
ASKING.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
let asking = std::thread::Builder::new().name("gridcraft-recent-files".into()).spawn(move || {
|
||||
let _done = Done;
|
||||
for path in paths {
|
||||
let changed = std::fs::metadata(&path).and_then(|m| m.modified()).ok().and_then(|at| at.duration_since(std::time::UNIX_EPOCH).ok());
|
||||
let answer = changed.map_or(Modified::Unknown, |since| Modified::At(since.as_secs_f64()));
|
||||
found.lock().unwrap_or_else(PoisonError::into_inner).insert(path, answer);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
asking.is_ok()
|
||||
}
|
||||
|
||||
/// The web build has no file system to ask.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn ask(_ctx: egui::Context, _paths: Vec<String>, _found: Arc<Mutex<HashMap<String, Modified>>>) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// How long ago, in words. The clock's time zone isn't known here, so the list tells ages rather
|
||||
/// than dates, which could be a day out. From two days on the counts are never one, so each
|
||||
/// language needs one form of "days", "weeks", "months" and "years".
|
||||
fn age(lang: Language, seconds: f64) -> String {
|
||||
const DAY: f64 = 86_400.0;
|
||||
let s = if seconds.is_finite() { seconds.max(0.0) } else { 0.0 };
|
||||
let counted = |pattern: &str, n: f64| lang.tr(pattern).replace("{n}", &(n.floor() as u64).to_string());
|
||||
if s < 60.0 {
|
||||
lang.tr("Just now").to_string()
|
||||
} else if s < 3600.0 {
|
||||
counted("{n} min ago", s / 60.0)
|
||||
} else if s < DAY {
|
||||
counted("{n} h ago", s / 3600.0)
|
||||
} else if s < 2.0 * DAY {
|
||||
lang.tr("Yesterday").to_string()
|
||||
} else if s < 14.0 * DAY {
|
||||
counted("{n} days ago", s / DAY)
|
||||
} else if s < 61.0 * DAY {
|
||||
counted("{n} weeks ago", s / (7.0 * DAY))
|
||||
} else if s < 731.0 * DAY {
|
||||
counted("{n} months ago", s / (30.44 * DAY))
|
||||
} else {
|
||||
counted("{n} years ago", s / (365.25 * DAY))
|
||||
}
|
||||
}
|
||||
|
||||
/// A large clickable row (icon, title, one-line description).
|
||||
fn action_row(ui: &mut Ui, icon: Icon, title: &str, detail: &str) -> egui::Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
@@ -395,13 +533,13 @@ fn square_button(ui: &mut Ui, icon: Icon, label: &str) -> egui::Response {
|
||||
|
||||
// ---------------------------------------------------------------- pages
|
||||
|
||||
fn home(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) {
|
||||
fn home(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) {
|
||||
let lang = app.ui.language;
|
||||
section(ui, lang, "New");
|
||||
new_tiles(app, ui, out);
|
||||
ui.add_space(22.0);
|
||||
section(ui, lang, "Recent");
|
||||
recent_list(app, ui, out, 8);
|
||||
recent_list(app, ui, bs, out, 8);
|
||||
if app.ui.recent.len() > 8 {
|
||||
ui.add_space(6.0);
|
||||
if ui.link(lang.tr("More workbooks")).clicked() {
|
||||
@@ -414,7 +552,7 @@ fn new_page(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) {
|
||||
new_tiles(app, ui, out);
|
||||
}
|
||||
|
||||
fn open_page(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) {
|
||||
fn open_page(app: &mut SheetApp, ui: &mut Ui, bs: &mut Backstage, out: &mut Outcome) {
|
||||
let lang = app.ui.language;
|
||||
if square_button(ui, Icon::OpenFolder, lang.tr("Browse…")).clicked() {
|
||||
let before = app.session.active().map(|d| d.uid);
|
||||
@@ -426,7 +564,7 @@ fn open_page(app: &mut SheetApp, ui: &mut Ui, out: &mut Outcome) {
|
||||
}
|
||||
ui.add_space(22.0);
|
||||
section(ui, lang, "Recent");
|
||||
recent_list(app, ui, out, usize::MAX);
|
||||
recent_list(app, ui, bs, out, usize::MAX);
|
||||
}
|
||||
|
||||
/// Review › Protect Workbook and File › Info › Protect Workbook: the protect dialog (with an
|
||||
@@ -824,6 +962,12 @@ fn options(app: &mut SheetApp, ui: &mut Ui) {
|
||||
app.run_or_alert("view.formulaBar", json!({"on": formula_bar}));
|
||||
}
|
||||
ui.add_space(18.0);
|
||||
section(ui, lang, "Options|Editing");
|
||||
let mut autocomplete = app.ui.autocomplete;
|
||||
if ui.checkbox(&mut autocomplete, lang.tr("Enable AutoComplete for cell values")).changed() {
|
||||
app.run_or_alert("options.autoComplete", json!({"on": autocomplete}));
|
||||
}
|
||||
ui.add_space(18.0);
|
||||
section(ui, lang, "Options|Save");
|
||||
let mut autosave = app.grid.autosave();
|
||||
if ui.checkbox(&mut autosave, lang.tr("Save .xlsx workbooks automatically after every change")).changed() {
|
||||
@@ -856,6 +1000,76 @@ fn options(app: &mut SheetApp, ui: &mut Ui) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ages_are_told_in_words() {
|
||||
const DAY: f64 = 86_400.0;
|
||||
for (seconds, words) in [
|
||||
(0.0, "Just now"),
|
||||
(59.9, "Just now"),
|
||||
(60.0, "1 min ago"),
|
||||
(150.0, "2 min ago"),
|
||||
(3600.0, "1 h ago"),
|
||||
(23.9 * 3600.0, "23 h ago"),
|
||||
(DAY, "Yesterday"),
|
||||
(1.99 * DAY, "Yesterday"),
|
||||
// From here on a count is never one.
|
||||
(2.0 * DAY, "2 days ago"),
|
||||
(13.9 * DAY, "13 days ago"),
|
||||
(14.0 * DAY, "2 weeks ago"),
|
||||
(60.9 * DAY, "8 weeks ago"),
|
||||
(61.0 * DAY, "2 months ago"),
|
||||
(730.9 * DAY, "24 months ago"),
|
||||
(731.0 * DAY, "2 years ago"),
|
||||
(4000.0 * DAY, "10 years ago"),
|
||||
// A clock that is behind the file's, or a number that isn't one.
|
||||
(-5.0, "Just now"),
|
||||
(f64::NAN, "Just now"),
|
||||
(f64::NEG_INFINITY, "Just now"),
|
||||
(f64::INFINITY, "Just now"),
|
||||
] {
|
||||
assert_eq!(age(Language::En, seconds), words, "{seconds}");
|
||||
}
|
||||
assert!(age(Language::En, f64::MAX).ends_with(" years ago"));
|
||||
// Other languages put the count where their own words want it.
|
||||
assert_eq!(age(Language::Ja, 3.0 * DAY), "3 日前");
|
||||
assert_eq!(age(Language::De, 3.0 * DAY), "vor 3 Tagen");
|
||||
assert_eq!(age(Language::Fr, 90.0), "il y a 1 min");
|
||||
for lang in Language::ALL {
|
||||
for seconds in [10.0, 600.0, 7200.0, 1.5 * DAY, 5.0 * DAY, 30.0 * DAY, 200.0 * DAY, 2000.0 * DAY] {
|
||||
let words = age(lang, seconds);
|
||||
assert!(!words.is_empty() && !words.contains('{'), "{lang:?} {seconds}: {words}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_recent_list_learns_when_its_files_were_last_changed() {
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-recent-files-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let there = dir.join("There.xlsx").to_string_lossy().into_owned();
|
||||
let gone = dir.join("Gone.xlsx").to_string_lossy().into_owned();
|
||||
std::fs::write(&there, b"a file, if not a workbook").unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let recent = vec![there.clone(), gone.clone()];
|
||||
let mut files = RecentFiles::default();
|
||||
// Nothing is read while drawing: the answers arrive from a thread, in later frames.
|
||||
let mut known = HashMap::new();
|
||||
for _ in 0..2_000 {
|
||||
known = files.known(&ctx, &recent);
|
||||
if known.len() == recent.len() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
assert!(matches!(known.get(&there), Some(Modified::At(seconds)) if *seconds > 1.0e9), "{known:?}");
|
||||
assert_eq!(known.get(&gone), Some(&Modified::Unknown));
|
||||
// The same list is not asked about twice; an empty one is never asked about.
|
||||
assert_eq!(files.known(&ctx, &recent), known);
|
||||
assert!(RecentFiles::default().known(&ctx, &[]).is_empty());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_ids_round_trip_and_unknown_ids_are_refused() {
|
||||
for page in Page::ALL {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user