mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-12 08:53:03 -06:00
Compare commits
258
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea80720c36 | ||
|
|
2b5a3e4a61 | ||
|
|
37941f0624 | ||
|
|
b1bd8d8b20 | ||
|
|
794b65ecac | ||
|
|
6308fc9edb | ||
|
|
48659c1668 | ||
|
|
e8845f648a | ||
|
|
3858482465 | ||
|
|
73a18c4f4c | ||
|
|
0b4b36b865 | ||
|
|
4251dad939 | ||
|
|
1b7e4f00dc | ||
|
|
4deedbb549 | ||
|
|
8d4fb0b12e | ||
|
|
203872b4db | ||
|
|
d3954beacf | ||
|
|
26e7112e0f | ||
|
|
3d31729de3 | ||
|
|
3ba4945e72 | ||
|
|
0752f3181b | ||
|
|
b24ec5df6d | ||
|
|
d0c567992e | ||
|
|
1c84e32f58 | ||
|
|
cf3b11c858 | ||
|
|
1e6c8b8b72 | ||
|
|
3e0f073e9a | ||
|
|
eec5db66f0 | ||
|
|
a440cfac28 | ||
|
|
92a79db048 | ||
|
|
652c08a89b | ||
|
|
aeb3fb19ce | ||
|
|
e4ec0d7ed1 | ||
|
|
23487b0cb5 | ||
|
|
0407b63663 | ||
|
|
b45698d109 | ||
|
|
c913bf6615 | ||
|
|
1bd4ca8946 | ||
|
|
0431741359 | ||
|
|
ae7fbd81b7 | ||
|
|
f800472b0a | ||
|
|
9c87c22662 | ||
|
|
c1adf9b239 | ||
|
|
21df49c636 | ||
|
|
7e0d96d74b | ||
|
|
572bec289b | ||
|
|
55eb394044 | ||
|
|
9e9f13114f | ||
|
|
35e9815603 | ||
|
|
6ea8d11522 | ||
|
|
7c3af8a421 | ||
|
|
ca7ea0131e | ||
|
|
aa0347cc2e | ||
|
|
5cf52c5fbb | ||
|
|
0c5b1e85e6 | ||
|
|
7f6a79fd98 | ||
|
|
6dbfbc4058 | ||
|
|
e258db5f19 | ||
|
|
763816d27c | ||
|
|
90180f4d87 | ||
|
|
d69a285efb | ||
|
|
94fc52efae | ||
|
|
7b825d00be | ||
|
|
7726e9c6ef | ||
|
|
9a025205bd | ||
|
|
01abd95420 | ||
|
|
e5ac9474df | ||
|
|
b21a8541e7 | ||
|
|
a6c331b63f | ||
|
|
12d26d53cc | ||
|
|
9dc7e3826a | ||
|
|
f79a425ca9 | ||
|
|
76e53085e3 | ||
|
|
bc2dabc7ea | ||
|
|
e14f783503 | ||
|
|
542aa176ce | ||
|
|
67a0b7f325 | ||
|
|
bbe0825404 | ||
|
|
aa4951fdf4 | ||
|
|
79fe7b8eb6 | ||
|
|
ff9f4b8ace | ||
|
|
5ce63e5fc2 | ||
|
|
b7fd198e06 | ||
|
|
55f3004cb2 | ||
|
|
022074344b | ||
|
|
ac4fb45c3f | ||
|
|
3d52a20dbd | ||
|
|
4cc2e21d7c | ||
|
|
088b43be28 | ||
|
|
942f639fbf | ||
|
|
a01ff3e7b3 | ||
|
|
b79e992576 | ||
|
|
0fed26a14f | ||
|
|
6a08240eb6 | ||
|
|
a3a98cf745 | ||
|
|
fe39331ac4 | ||
|
|
a84259d58c | ||
|
|
85d32c20be | ||
|
|
1deb108a7c | ||
|
|
2c9e2d1eec | ||
|
|
9c8b4a37ea | ||
|
|
a36e4987c1 | ||
|
|
457bc7e374 | ||
|
|
50a9e9ede4 | ||
|
|
74ab5cc969 | ||
|
|
ec12eba83e | ||
|
|
ca39b5ab9f | ||
|
|
e9addf52fe | ||
|
|
cda767b49b | ||
|
|
c35234e5f5 | ||
|
|
004523c286 | ||
|
|
517a4b9256 | ||
|
|
7102429a9a | ||
|
|
76416d4869 | ||
|
|
3004a05e46 | ||
|
|
01033f2c8f | ||
|
|
99bbd237fc | ||
|
|
642fa97fa5 | ||
|
|
63150810b3 | ||
|
|
2c58c76914 | ||
|
|
c000e1b3ff | ||
|
|
dae5b52d78 | ||
|
|
684f051f0d | ||
|
|
21920d3d61 | ||
|
|
a2509bdf8b | ||
|
|
0f5a860d74 | ||
|
|
91bd22635e | ||
|
|
17131fb0e5 | ||
|
|
94a8fb084a | ||
|
|
fca22f6e93 | ||
|
|
300b074894 | ||
|
|
9dd3b51bc6 | ||
|
|
4a93722ea6 | ||
|
|
6338cef450 | ||
|
|
24119e49fe | ||
|
|
cbd0879c44 | ||
|
|
0057fc00c9 | ||
|
|
9ab297ced1 | ||
|
|
2ee261c0e8 | ||
|
|
7cb0088361 | ||
|
|
4bba98fd50 | ||
|
|
640fbf22f6 | ||
|
|
865a7f6a06 | ||
|
|
1458f45214 | ||
|
|
05a5845234 | ||
|
|
762be28e29 | ||
|
|
8e5bcf1575 | ||
|
|
83670dfe3a | ||
|
|
019033e095 | ||
|
|
93dab0c420 | ||
|
|
dea064b857 | ||
|
|
9354fdb0c4 | ||
|
|
5f84fadaf5 | ||
|
|
9ba79c3e38 | ||
|
|
6a7cf9cf66 | ||
|
|
c35878948a | ||
|
|
42370639e0 | ||
|
|
6b19e445f4 | ||
|
|
05cffdceb1 | ||
|
|
87cb0f11d2 | ||
|
|
12f69f99bf | ||
|
|
225496e544 | ||
|
|
c125ce4780 | ||
|
|
d7c0158f0a | ||
|
|
76002fe0bd | ||
|
|
505cae34c8 | ||
|
|
ee1b5df3db | ||
|
|
cbd765332c | ||
|
|
b9e5e0ff89 | ||
|
|
66bb6ae239 | ||
|
|
bc1625248e | ||
|
|
4fc2c9c3b3 | ||
|
|
546a546943 | ||
|
|
8774fa45fb | ||
|
|
8325dbe50e | ||
|
|
fb17d0d1d5 | ||
|
|
89ec06f041 | ||
|
|
20473a4fae | ||
|
|
1688b43ae2 | ||
|
|
7d3ede854f | ||
|
|
6a8eb4ff42 | ||
|
|
558312cb53 | ||
|
|
23c909e477 | ||
|
|
0955378d8e | ||
|
|
756d5981b1 | ||
|
|
7e828a982c | ||
|
|
51e242e1fa | ||
|
|
8c1c7cfd27 | ||
|
|
b3a709f314 | ||
|
|
86c5c8dd81 | ||
|
|
f077b9ef36 | ||
|
|
40e75c7912 | ||
|
|
d8ba397bb7 | ||
|
|
7e8b94fc39 | ||
|
|
22c06b7a4a | ||
|
|
1ff10df557 | ||
|
|
472132b158 | ||
|
|
94eed2bd83 | ||
|
|
bcad4a75cf | ||
|
|
e85b24df57 | ||
|
|
4fe0816ca0 | ||
|
|
00f565bc11 | ||
|
|
4b98b11996 | ||
|
|
21504859c8 | ||
|
|
a9db1da90d | ||
|
|
96672e8ad5 | ||
|
|
628da5117a | ||
|
|
e7cda6337b | ||
|
|
33cadfc0a8 | ||
|
|
494a7dbb5c | ||
|
|
95ba076778 | ||
|
|
bdbd29e623 | ||
|
|
edfde80f95 | ||
|
|
54ba74cfc0 | ||
|
|
c1e1c7bdc9 | ||
|
|
6c912d2eb1 | ||
|
|
1801f4a7cf | ||
|
|
c3865b5dbd | ||
|
|
95c3bb9b89 | ||
|
|
72e959f117 | ||
|
|
bc7e37fa7d | ||
|
|
8b330ac9e2 | ||
|
|
b6bbdcd5cf | ||
|
|
4523b817ce | ||
|
|
d71cf10e70 | ||
|
|
b7c5a69dbd | ||
|
|
f404a08357 | ||
|
|
a434efabf0 | ||
|
|
2fe8a33c5d | ||
|
|
2f36fe1b34 | ||
|
|
5144ebe1de | ||
|
|
5f3fcc8140 | ||
|
|
356ccdcf1a | ||
|
|
0b7f674925 | ||
|
|
c31e4af95c | ||
|
|
793420b849 | ||
|
|
72236e4aab | ||
|
|
6c37a63704 | ||
|
|
c567511cf1 | ||
|
|
96bfba93f8 | ||
|
|
890870cd57 | ||
|
|
ee13457b2f | ||
|
|
8ae680547a | ||
|
|
8f34c42e88 | ||
|
|
6c32845740 | ||
|
|
a34e109451 | ||
|
|
c0d0d909fc | ||
|
|
562004f198 | ||
|
|
7f8ac83dc3 | ||
|
|
4ab3edaf3b | ||
|
|
9977d850a4 | ||
|
|
4a1e6de303 | ||
|
|
79cfd9b151 | ||
|
|
5cf0e24f43 | ||
|
|
d446fde718 | ||
|
|
423810750d | ||
|
|
d2b9affbc5 | ||
|
|
691883f99c |
@@ -0,0 +1,29 @@
|
||||
# This file is machine-generated by `gh actions-lock`.
|
||||
# Do not edit by hand; run `gh actions-lock` to update.
|
||||
# Docs: https://gh.io/actions-lockfile
|
||||
version: 'v0.0.3'
|
||||
workflows:
|
||||
'.github/workflows/ci.yml': []
|
||||
'.github/workflows/freebsd.yml': []
|
||||
'.github/workflows/house-rules.yml': []
|
||||
'.github/workflows/lockfile.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'step-security/harden-runner@v2.22.4'
|
||||
'.github/workflows/nix.yml': []
|
||||
'.github/workflows/packaging-lint.yml':
|
||||
- 'actions/checkout@v7.0.1'
|
||||
- 'step-security/harden-runner@v2.22.4'
|
||||
'.github/workflows/release.yml': []
|
||||
'.github/workflows/sonarcloud.yml': []
|
||||
'.github/workflows/windows-arm64.yml': []
|
||||
dependencies:
|
||||
'actions/checkout@v7.0.1':
|
||||
ref: 'v7.0.1'
|
||||
commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'
|
||||
owner_id: 44036562
|
||||
repo_id: 197814629
|
||||
'step-security/harden-runner@v2.22.4':
|
||||
ref: 'v2.22.4'
|
||||
commit: 'sha1-1d88384fd173af6ecb012dfd0264a306b711241a'
|
||||
owner_id: 88700172
|
||||
repo_id: 422287306
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Lockfile
|
||||
|
||||
# Fails when .github/workflows/actions.lock is out of sync with the workflows, or when a locked pin
|
||||
# no longer verifies upstream, so a moved tag cannot slip a different commit into a run.
|
||||
# Docs: https://gh.io/actions-lockfile
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
jobs:
|
||||
lockfile:
|
||||
name: Lockfile is in sync
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install gh-actions-lock
|
||||
run: gh extension install github/gh-actions-lock --pin v0.4.1
|
||||
- name: Verify the lockfile
|
||||
run: gh actions-lock --verify
|
||||
@@ -27,7 +27,11 @@ jobs:
|
||||
name: Windows installation policy (no app build)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install the pinned packaging toolchain
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
# A nightly dry run on main: every release build except macOS, nothing signed or released
|
||||
# (craft-common-actions release.yml `dry-run`). Finds release-only breakage before release day.
|
||||
schedule:
|
||||
- cron: "56 1 * * *"
|
||||
push:
|
||||
branches: [release]
|
||||
workflow_dispatch:
|
||||
@@ -23,6 +27,7 @@ jobs:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
dry-run: ${{ github.event_name == 'schedule' }}
|
||||
app-name: gridcraft
|
||||
app-title: GridCraft
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
|
||||
@@ -12,6 +12,7 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
|
||||
| `epaint_default_fonts::HACK_REGULAR` (dependency font input) | Source Foundry Authors (Hack; DejaVu and Bitstream Vera foundations) | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Hack](https://github.com/source-foundry/Hack) | MIT + Bitstream Vera License (full upstream texts in `NOTICE`) | Hack-Regular.ttf is egui's bundled monospace face and GridCraft's last-resort symbol fallback. Dependency asset unmodified here, no font file copied into this repository. |
|
||||
| `epaint_default_fonts::NOTO_EMOJI_REGULAR` (dependency font input) | The Noto Project Authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Noto Emoji](https://fonts.google.com/noto/specimen/Noto+Emoji) | SIL Open Font License 1.1 (full upstream text in `NOTICE`) | NotoEmoji-Regular.ttf is enabled by egui's `monochrome_emoji_fonts` feature and used as the monochrome emoji fallback. Dependency asset unmodified here, no font file copied into this repository. |
|
||||
| `epaint_default_fonts::UBUNTU_LIGHT` (dependency font input) | Canonical Ltd. and the Ubuntu Font Family authors | [egui 6d518fc](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Ubuntu font](https://design.ubuntu.com/font) | Ubuntu Font Licence 1.0 (full upstream text in `NOTICE`) | Ubuntu-Light.ttf is egui's default proportional face. Dependency asset unmodified here, no font file copied into this repository. |
|
||||
| Regional format data in `crates/core/src/region.rs` (data, no file of its own) | Unicode, Inc. (Unicode CLDR) | [cldr-json](https://github.com/unicode-org/cldr-json) (`cldr-numbers-full`, `cldr-dates-full`) and ICU's CLDR 48 data | Unicode License v3 (`Unicode-3.0`; full text in `NOTICE`) | Separators, month and weekday names, AM/PM words, currency symbols and date order of the built-in regions. No CLDR file is copied into this repository. |
|
||||
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-logo.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms | Trademark |
|
||||
|
||||
Generated
+208
-35
@@ -739,6 +739,12 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||
|
||||
[[package]]
|
||||
name = "cmpv2"
|
||||
version = "0.2.0"
|
||||
@@ -757,7 +763,7 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"const-oid 0.9.6",
|
||||
"der",
|
||||
"spki",
|
||||
"x509-cert",
|
||||
@@ -827,6 +833,12 @@ version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be95d4a884358a21a199689323c29e9256b4a1b315e22f183d155422041d3a9a"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
@@ -907,15 +919,28 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-cfb"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
|
||||
[[package]]
|
||||
name = "craft-csv"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=aa84415bae3b765fef6b7ef42404f66ad1cff9d9#aa84415bae3b765fef6b7ef42404f66ad1cff9d9"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=aa84415bae3b765fef6b7ef42404f66ad1cff9d9#aa84415bae3b765fef6b7ef42404f66ad1cff9d9"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
@@ -935,10 +960,38 @@ dependencies = [
|
||||
"writeable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-offcrypto"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64 0.23.1",
|
||||
"craft-cfb",
|
||||
"craft-xml",
|
||||
"getrandom 0.3.4",
|
||||
"hmac",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.1",
|
||||
"thiserror 2.0.21",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-opc"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
dependencies = [
|
||||
"craft-xml",
|
||||
"craft-zip",
|
||||
"log",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=aa84415bae3b765fef6b7ef42404f66ad1cff9d9#aa84415bae3b765fef6b7ef42404f66ad1cff9d9"
|
||||
dependencies = [
|
||||
"craft-winprint-gdi",
|
||||
"log",
|
||||
@@ -949,10 +1002,15 @@ dependencies = [
|
||||
"winsafe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-sheet"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=aa84415bae3b765fef6b7ef42404f66ad1cff9d9#aa84415bae3b765fef6b7ef42404f66ad1cff9d9"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
@@ -972,7 +1030,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -982,6 +1040,7 @@ dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"plist",
|
||||
"rustix 1.1.5",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -995,10 +1054,21 @@ dependencies = [
|
||||
"zip 9.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-api"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=aa84415bae3b765fef6b7ef42404f66ad1cff9d9#aa84415bae3b765fef6b7ef42404f66ad1cff9d9"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=aa84415bae3b765fef6b7ef42404f66ad1cff9d9#aa84415bae3b765fef6b7ef42404f66ad1cff9d9"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
@@ -1007,7 +1077,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"objc2 0.6.5",
|
||||
@@ -1018,11 +1088,43 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-winprint-gdi"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/winprint.rs?rev=417ac3c4d14d3da5485a99c7d5ce8f05664ec312#417ac3c4d14d3da5485a99c7d5ce8f05664ec312"
|
||||
source = "git+https://github.com/storytold/winprint.rs?rev=64e7afd4d1483512385012c340ecaaaf0f3beb7f#64e7afd4d1483512385012c340ecaaaf0f3beb7f"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-xls"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
dependencies = [
|
||||
"craft-cfb",
|
||||
"craft-sheet",
|
||||
"md-5",
|
||||
"miniz_oxide 0.9.1",
|
||||
"sha1 0.10.7",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-xml"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
dependencies = [
|
||||
"quick-xml 0.41.0",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-zip"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb57d422d0bc842cbde430955825553ab7b976f4#bb57d422d0bc842cbde430955825553ab7b976f4"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"thiserror 2.0.21",
|
||||
"zip 9.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -1103,6 +1205,15 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cursor-icon"
|
||||
version = "1.2.0"
|
||||
@@ -1115,7 +1226,7 @@ version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"const-oid 0.9.6",
|
||||
"der_derive",
|
||||
"flagset",
|
||||
"pem-rfc7468",
|
||||
@@ -1180,7 +1291,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.1",
|
||||
"const-oid 0.10.3",
|
||||
"crypto-common 0.2.2",
|
||||
"ctutils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1871,9 +1984,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2011,7 +2126,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft"
|
||||
version = "0.16.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -2023,6 +2138,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"gridcraft-engine",
|
||||
"gridcraft-mcp",
|
||||
"gridcraft-sandbox",
|
||||
"gridcraft-ui-egui",
|
||||
"image",
|
||||
"log",
|
||||
@@ -2038,7 +2154,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-calc"
|
||||
version = "0.16.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
@@ -2050,7 +2166,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-chart"
|
||||
version = "0.16.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"gridcraft-calc",
|
||||
"gridcraft-core",
|
||||
@@ -2061,10 +2177,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-cli"
|
||||
version = "0.16.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"gridcraft-mcp",
|
||||
"gridcraft-sandbox",
|
||||
"log",
|
||||
"quick-xml 0.41.0",
|
||||
"serde_json",
|
||||
@@ -2073,7 +2190,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-core"
|
||||
version = "0.16.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"js-sys",
|
||||
@@ -2084,8 +2201,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-engine"
|
||||
version = "0.16.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"craft-csv",
|
||||
"gridcraft-calc",
|
||||
"gridcraft-chart",
|
||||
"gridcraft-core",
|
||||
@@ -2107,7 +2225,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-formula"
|
||||
version = "0.16.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"serde",
|
||||
@@ -2116,7 +2234,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-functions"
|
||||
version = "0.16.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"regex",
|
||||
@@ -2124,7 +2242,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-math"
|
||||
version = "0.16.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"ratex-font",
|
||||
"ratex-layout",
|
||||
@@ -2134,7 +2252,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-mcp"
|
||||
version = "0.16.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"log",
|
||||
@@ -2143,7 +2261,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-model"
|
||||
version = "0.16.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
@@ -2156,18 +2274,29 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-numfmt"
|
||||
version = "0.16.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"gridcraft-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-pdf"
|
||||
version = "0.16.0"
|
||||
version = "0.19.1"
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-sandbox"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"landlock",
|
||||
"libc",
|
||||
"nix",
|
||||
"seccompiler",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-ui-egui"
|
||||
version = "0.16.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
@@ -2186,6 +2315,7 @@ dependencies = [
|
||||
"skrifa",
|
||||
"sys-locale",
|
||||
"toml",
|
||||
"unicode-bidi",
|
||||
"web-sys",
|
||||
"web-time",
|
||||
"zbus",
|
||||
@@ -2193,8 +2323,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-web"
|
||||
version = "0.16.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"craft-web-api",
|
||||
"craft-web-host",
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -2211,16 +2342,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gridcraft-xlsx"
|
||||
version = "0.16.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"craft-cfb",
|
||||
"craft-csv",
|
||||
"craft-offcrypto",
|
||||
"craft-opc",
|
||||
"craft-xls",
|
||||
"gridcraft-core",
|
||||
"gridcraft-formula",
|
||||
"gridcraft-functions",
|
||||
"gridcraft-math",
|
||||
"gridcraft-model",
|
||||
"quick-xml 0.41.0",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.1",
|
||||
"thiserror 2.0.21",
|
||||
"zip 3.0.0",
|
||||
@@ -2325,6 +2459,15 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
|
||||
dependencies = [
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.5.0"
|
||||
@@ -2860,6 +3003,17 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "landlock"
|
||||
version = "0.4.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cca98e95f35b29d469dade6724c6f96cec9236640f745a0e99b0334ec320ab1"
|
||||
dependencies = [
|
||||
"enumflags2",
|
||||
"libc",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.1"
|
||||
@@ -2954,6 +3108,16 @@ version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
@@ -4531,6 +4695,15 @@ dependencies = [
|
||||
"tiny-skia",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "seccompiler"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4ae55de56877481d112a559bbc12667635fdaf5e005712fd4e2b2fa50ffc884"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -4757,7 +4930,7 @@ checksum = "da90c3d9af638898a5fdc347479b18f950bb0dde11ecf2244f94cd28135da53b"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"base64 0.22.1",
|
||||
"const-oid",
|
||||
"const-oid 0.9.6",
|
||||
"der",
|
||||
"digest 0.10.7",
|
||||
"pem",
|
||||
@@ -4831,7 +5004,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"cmpv2",
|
||||
"cms",
|
||||
"const-oid",
|
||||
"const-oid 0.9.6",
|
||||
"der",
|
||||
"hex",
|
||||
"rand 0.10.3",
|
||||
@@ -4870,7 +5043,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"cms",
|
||||
"const-oid",
|
||||
"const-oid 0.9.6",
|
||||
"hex",
|
||||
"pem",
|
||||
"rustls-pki-types",
|
||||
@@ -6473,7 +6646,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "winprint"
|
||||
version = "0.2.1"
|
||||
source = "git+https://github.com/storytold/winprint.rs?rev=5f80b9baf6b58937c401656634e42d33f00ddf49#5f80b9baf6b58937c401656634e42d33f00ddf49"
|
||||
source = "git+https://github.com/storytold/winprint.rs?rev=bc06a7c48b768eb27a828c7986f6d2868b245ee0#bc06a7c48b768eb27a828c7986f6d2868b245ee0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"fmt-derive",
|
||||
@@ -6488,6 +6661,7 @@ dependencies = [
|
||||
"windows",
|
||||
"windows-core",
|
||||
"windows-implement",
|
||||
"windows-numerics",
|
||||
"xml",
|
||||
]
|
||||
|
||||
@@ -6594,7 +6768,7 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"const-oid 0.9.6",
|
||||
"der",
|
||||
"sha1 0.10.7",
|
||||
"signature",
|
||||
@@ -6662,7 +6836,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.16.0"
|
||||
version = "0.19.1"
|
||||
dependencies = [
|
||||
"gridcraft-engine",
|
||||
"serde_json",
|
||||
@@ -6923,7 +7097,6 @@ dependencies = [
|
||||
"nix",
|
||||
"typed-path",
|
||||
"windows-sys 0.61.2",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+17
-8
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/gridcraft", "apps/gridcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.16.0"
|
||||
version = "0.19.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -14,6 +14,7 @@ repository = "https://github.com/storytold/gridcraft"
|
||||
# internal
|
||||
gridcraft-core = { path = "crates/core" }
|
||||
gridcraft-numfmt = { path = "crates/numfmt" }
|
||||
gridcraft-sandbox = { path = "crates/sandbox" }
|
||||
gridcraft-formula = { path = "crates/formula" }
|
||||
gridcraft-functions = { path = "crates/functions" }
|
||||
gridcraft-model = { path = "crates/model" }
|
||||
@@ -25,10 +26,21 @@ gridcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
gridcraft-mcp = { path = "crates/mcp" }
|
||||
gridcraft-pdf = { path = "crates/pdf" }
|
||||
gridcraft-math = { path = "crates/math" }
|
||||
# Every craft-libs crate (craft-update and craft-window here, craft-ui in ui-egui, craft-print in
|
||||
# the app) comes from one storytold/craft-libs revision.
|
||||
# Every craft-libs crate (craft-update, craft-window and the file-format crates here, craft-ui in
|
||||
# ui-egui, craft-print in the app, craft-web-api and craft-web-host in the web app) comes from one
|
||||
# craft-libs revision.
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", features = ["egui"] }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "=0.1.0", features = ["egui"] }
|
||||
# Compound files ([MS-CFB]) and password-encrypted OOXML packages ([MS-OFFCRYPTO]).
|
||||
craft-cfb = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "0.1.0" }
|
||||
craft-offcrypto = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "0.1.0" }
|
||||
# OPC zip packages (parts, relationships, the zip writer) under `crates/xlsx/src/package.rs`.
|
||||
craft-opc = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "0.1.0" }
|
||||
# Delimited text (CSV, TSV) and SYLK; `crates/xlsx/src/csv.rs` and `crates/engine/src/io/sylk.rs`
|
||||
# turn them into workbooks.
|
||||
craft-csv = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "0.1.0" }
|
||||
# Excel 97-2003 (.xls, BIFF8) workbooks; `crates/xlsx/src/xls.rs` maps them onto the model.
|
||||
craft-xls = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "0.1.0" }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
@@ -43,9 +55,6 @@ regex = { version = "1", default-features = false, features = ["std", "unicode",
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
sha2 = { version = "0.11", default-features = false }
|
||||
# Decrypting password-protected workbooks ([MS-OFFCRYPTO]): AES and SHA-1, pure Rust (wasm-safe).
|
||||
aes = { version = "0.9", default-features = false }
|
||||
sha1 = { version = "0.11", default-features = false }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
|
||||
# equations: RaTeX (MIT) lays LaTeX out as positioned glyphs, rules and paths. Exact pins: it is
|
||||
@@ -71,7 +80,7 @@ skrifa = { version = "0.44", default-features = false, features = ["std"] }
|
||||
epaint_default_fonts = "0.37"
|
||||
# One native window per workbook: the window list, Window menu titles and the immediate-viewport
|
||||
# driver. Same craft-libs revision as craft-update above.
|
||||
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", default-features = false, features = ["egui"] }
|
||||
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", default-features = false, features = ["egui"] }
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
@@ -275,6 +275,55 @@ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
|
||||
DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
|
||||
The regional format data in crates/core/src/region.rs (separators, month and weekday names, AM/PM
|
||||
words, currency symbols and date order) follows the Unicode Common Locale Data Repository (CLDR),
|
||||
read from unicode-org/cldr-json and from ICU's CLDR 48 data, under the Unicode License v3.
|
||||
Source: https://github.com/unicode-org/cldr-json
|
||||
The following text is reproduced verbatim from cldr-json's LICENSE:
|
||||
|
||||
UNICODE LICENSE V3
|
||||
|
||||
COPYRIGHT AND PERMISSION NOTICE
|
||||
|
||||
Copyright © 2015-2024 Unicode, Inc.
|
||||
|
||||
NOTICE TO USER: Carefully read the following legal agreement. BY
|
||||
DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR
|
||||
SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE
|
||||
TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT
|
||||
DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of data files and any associated documentation (the "Data Files") or
|
||||
software and any associated documentation (the "Software") to deal in the
|
||||
Data Files or Software without restriction, including without limitation
|
||||
the rights to use, copy, modify, merge, publish, distribute, and/or sell
|
||||
copies of the Data Files or Software, and to permit persons to whom the
|
||||
Data Files or Software are furnished to do so, provided that either (a)
|
||||
this copyright and permission notice appear with all copies of the Data
|
||||
Files or Software, or (b) this copyright and permission notice appear in
|
||||
associated Documentation.
|
||||
|
||||
THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
|
||||
KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
|
||||
THIRD PARTY RIGHTS.
|
||||
|
||||
IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
|
||||
BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,
|
||||
OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
|
||||
WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
|
||||
ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA
|
||||
FILES OR SOFTWARE.
|
||||
|
||||
Except as contained in this notice, the name of a copyright holder shall
|
||||
not be used in advertising or otherwise to promote the sale, use or other
|
||||
dealings in these Data Files or Software without prior written
|
||||
authorization of the copyright holder.
|
||||
|
||||
SPDX-License-Identifier: Unicode-3.0
|
||||
|
||||
Linux Wayland window decorations
|
||||
-------------------------------
|
||||
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
|
||||
|
||||
+17
-4
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
|
||||
|
||||
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-02 · **Change:** minor (form controls: buttons, sliders and spin buttons; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis take ranges by selection on the grid; earlier: Python mode in the formula bar; earlier: Python cells stored and kept in files; earlier: equations: Insert › Equation, editor, gridcraft-math; earlier: What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear; earlier: floating objects: one selection, eight resize handles, keyboard delete; earlier: Chart data tables: live source values beneath column, bar, line, area and compatible combo charts; legend keys and borders; XLSX round-trip and print/PDF; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-05 · **Change:** minor (CJK fallback text shares the Latin baseline; earlier: form controls: buttons, sliders and spin buttons; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis take ranges by selection on the grid; earlier: Python mode in the formula bar; earlier: Python cells stored and kept in files; earlier: equations: Insert › Equation, editor, gridcraft-math; earlier: What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear; earlier: floating objects: one selection, eight resize handles, keyboard delete; earlier: Chart data tables: live source values beneath column, bar, line, area and compatible combo charts; legend keys and borders; XLSX round-trip and print/PDF; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365)
|
||||
|
||||
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
|
||||
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
|
||||
@@ -43,8 +43,7 @@ long `.xls` track, so about half parallelizes (≈30–50 wall-clock hours with
|
||||
|
||||
Why alpha: all six core workflows in the [alpha gate](docs/roadmap.md#alpha-gate) pass end to end on macOS with save and reopen, and the core workflow works end to end (open an XLSX, enter formulas and data, format, sort
|
||||
and filter, chart, pivot, print to PDF, save XLSX), but users still hit blocking gaps on real
|
||||
files: 100 MB workbooks don't open (#175), there's no `.xls` (#121), Arabic and Devanagari text
|
||||
isn't shaped (Chinese text still reported in #142), charts lose their type and options on round trip, and
|
||||
files: 100 MB workbooks don't open (#175), there's no `.xls` (#121), Devanagari text needs shaping verification (Chinese text still reported in #142), charts lose their type and options on round trip, and
|
||||
nobody has yet confirmed that Excel opens our output without a repair prompt across a real
|
||||
corpus. Beta needs those closed (see [beta gates](docs/roadmap.md#beta-gates)).
|
||||
|
||||
@@ -105,7 +104,7 @@ Localized German formulas and numbers are in PR #163. Detail: [localization-pari
|
||||
| Simplified Chinese | zh-Hans | menus only | ~12% |
|
||||
| Spanish | es | none | 0% |
|
||||
| Hindi | hi | none | 0% |
|
||||
| Arabic | ar | none (no RTL layout, no shaping) | 0% |
|
||||
| Arabic | ar | catalog pending; shared shaping/bidi works | 0% |
|
||||
| French | fr | none | 0% |
|
||||
| Portuguese | pt-BR | menus, File view, dialogs | ~43% |
|
||||
| Indonesian | id | none | 0% |
|
||||
@@ -135,6 +134,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-11-06 | A sheet keeps its own scroll position when sheets are inserted, deleted or moved before it: a new PivotTable or inserted sheet opens at the top instead of where its neighbour was scrolled; a closed window's positions are let go (#3485) |
|
||||
| 2026-11-02 | Form controls: Insert › Controls places buttons, sliders (scroll bars) and spin buttons on the sheet. A slider or spin button holds a whole number from 0 to 30000 in a linked cell (a drag or a held arrow is one undo step, and editing the cell moves the control); a button runs a macro or one command through the macro policy, asking first in a workbook opened from a file; Assign Action and Format Control edit them, and XLSX files read and write them (control properties, `<controls>` and VML), alongside the form controls GridCraft keeps unchanged |
|
||||
| 2026-11-02 | Dialogs take ranges by selection on the grid: reference boxes (RefEdit) with a collapse button that shrinks the dialog while the person selects, in Goal Seek, Solver (including the Model Range of Load/Save) and Data Analysis; while collapsed, the arrow keys move the selection and Shift+arrow extends it, and F4 cycles a reference's anchors |
|
||||
| 2026-11-02 | Python mode in the formula bar: Ctrl+Alt+Shift+P, `=PY` then Tab, or editing a Python cell edits its code in the formula bar, with Enter for a new line, Tab to indent and Ctrl+Enter to commit through `python.set`. The code still doesn't run |
|
||||
@@ -148,6 +148,9 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-31 | CSV and text open detects each column's date order: a value that reads only day first (`31/05/2026`) makes the column day first, a value that reads only month first (`12/31/2026`) makes it month first, an all-ambiguous column keeps month first with a warning, and a column with both kinds stays text with a warning (#2765) |
|
||||
| 2026-10-31 | Recently Used lists the functions most recently entered in a formula from any source (ribbon, control channel, MCP, scripts), kept with the preferences and returned by `formulas.recentlyUsed`; the Insert Function dialog takes Excel's title and a working category list (Most Recently Used, All, each category) |
|
||||
| 2026-10-31 | Formulas › Function Library as a big Insert Function button, the AutoSum split button and three columns of labelled small buttons, each category book in its own colour; category menus end with Insert Function… on that category; More Functions cascades into the remaining categories; Home › Editing shares the AutoSum menu; the MR key tip opens Recently Used |
|
||||
| 2026-10-31 | System and embedded CJK fallback text shares the Latin baseline in cells, editors and window titles (#1265, #2621). Symbol and emoji alignment, manual row heights and clipping are unchanged |
|
||||
| 2026-10-26 | Arabic bidi display/editing preserve logical source text; per-cell reading direction is editable and survives undo, XLSX and conditional-format priority |
|
||||
| 2026-10-26 | Arabic text shows in cells and the interface through a system, fontconfig or craft-fonts Arabic face; workbook fonts keep their script fallbacks |
|
||||
| 2026-10-25 | AutoSum over a selection does what Excel does: a selected row gets one total to its right (in its last cell if that is empty), not a one-cell total under each cell; a block whose last column is empty gets each row's total there, one whose last row is empty each column's, and with both the corner gets the total of the block; otherwise totals go under each column as before |
|
||||
| 2026-10-24 | Macro interface ([macros.md](docs/macros.md#in-the-window)): an Automate › Macros group (Record Macro, which turns into Stop Recording, Use Relative References, Run Macro with recent macros); "Recording · N steps" with Stop in the status bar; a Record Macro dialog (checked name, free Ctrl+Shift shortcut, description); the Macros dialog lists macros beside Automate scripts and a VBA project, with Run, Step Into, Edit, Create, Delete and Options; Ctrl+Shift+letter macro shortcuts; questions that offer Run, Overwrite, Shift Down or Shift Right; a step editor with a breakpoint gutter, drag to reorder, a command picker, reference boxes that switch between relative tokens and absolute references, Inputs and Output tabs, a debug bar (Check, Run, Step, Continue, Run to Here, Stop with keep or undo) and grid overlays for inputs, writes, conflicts and the current step |
|
||||
| 2026-10-24 | Go To lists the named ranges of every sheet (`Sheet2!Name` for a name scoped to another sheet; hidden names, constants and formulas left out) and the references entered before, a double-click goes to an entry, and the Reference box takes several names or references separated by commas (`Price, Type`, `B14:C22,F19:G30`) |
|
||||
@@ -186,6 +189,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-14 | Recalculation: a dynamic array's anchor given a new formula that reads its old spill area (`A1` was `=SEQUENCE(3)`, becomes `=SUM(A2:A3)+1` or `=SEQUENCE(1,A2+1)`) sees that area empty instead of the old values |
|
||||
| 2026-10-14 | Recalculation: when two arrays of one recalculation want the same empty cells, the formulas that read the one laid out second (at its anchor or over the cells it would have spilled into) read `#SPILL!` and empty cells instead of its values |
|
||||
| 2026-10-14 | Border keytips: each Alt, H, B letter applies the preset Excel's Borders menu gives it (H,B,O bottom, H,B,B bottom double, H,B,N no border, H,B,A all borders and the rest); they all drew a bottom border before. H,B,M opens Format Cells on the Border tab |
|
||||
| 2026-10-13 | Charts: label shortening needs about nine text measurements instead of up to 400, side legends stop measuring once the column is at its cap and treemap layout avoids per-item allocation (#1149) |
|
||||
| 2026-10-13 | Formula-entry Command/Ctrl-click inserts another reference with the locale's argument separator; Shift-click extends from a stable, sheet-aware anchor. Pointer tests cover repeated clicks, reversed ranges, ordinary replacement, typed separators, Spanish syntax, sheet switches and keyboard continuation. Readiness estimates unchanged. |
|
||||
| 2026-10-13 | `formulas.evaluate` (`gridcraft-cli eval`, MCP `evaluate_formula`) refuses what entering the formula in a cell refuses, with the reason (`=SUM(1;2)`: "expected `,` or `)`"), instead of `#NAME?` for any formula that doesn't parse |
|
||||
| 2026-10-13 | Colour menus: Fill Color, Font Color and the Format Cells pickers end with Recent Colors (the last ten custom colours) and More Colors… (hue/saturation area, RGB, hex box, preview, OK/Cancel) (#928) |
|
||||
@@ -195,7 +199,10 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-13 | XLSX: `[Content_Types].xml`, `_rels/.rels`, the workbook rels and `xl/workbook.xml` are the first zip entries (document properties last), so libmagic and PHP `finfo` detect the file type even with large properties (#1090) |
|
||||
| 2026-10-13 | Conditional formatting: a rule gathers range statistics only when it reads them (top/bottom, above average, duplicates, scales, bars, icons) and borrows them instead of copying them per cell, so the per-frame lookup of an expression or cell-value rule no longer visits its whole range (#1098, part 1) |
|
||||
| 2026-10-12 | Next/previous sheet navigation stops at the first/last visible worksheet instead of activating a hidden boundary sheet; the current selection is retained |
|
||||
| 2026-10-12 | Number formatting: one table of built-in formats (xlsx); numbers, dates and General render with one scratch buffer instead of per-token strings; output unchanged, checked against digests recorded from the old code (#817) |
|
||||
| 2026-10-21 | Macro engine ([macros.md](docs/macros.md)): typed, declarative macros saved in the workbook (a JSON field; hidden names in xlsx); declared inputs checked with messages that name the cells; tokens (`Input.col(2)`, `Row`, `Output`, `Name:Total`, `{{…}}` in formulas); one undo step with full rollback; a footprint dry run with ask, fail, overwrite, shift down or shift right; a policy that keeps macros away from files and the app, with approval for macros in opened files; Repeat, If and Stop steps; a debugger; a relative recorder; `macro.*` commands beside the unchanged Automate scripts |
|
||||
| 2026-10-13 | Charts: work and memory are bounded by series x points (2,000,000 per chart: every series keeps its first points; 16 trendlines per chart), hairline columns and bars are merged per pixel column, series ranges are clipped before they are evaluated, the moving average is linear and rendering clean data no longer copies it (#1147) |
|
||||
| 2026-10-13 | Charts: one combo plot-kind rule for resolving and drawing; ticks of a scale off the step grid keep their own values (#1146) |
|
||||
| 2026-10-12 | Security: the `--control` server requires an auth token (random, kept in a 0600 file or chosen with `GRIDCRAFT_CONTROL_TOKEN`) on each connection's first line and closes bad connections (#777) |
|
||||
| 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) |
|
||||
@@ -259,6 +266,8 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-06 | trivial | Progress log: scroll positions follow their sheet through sheet inserts, deletes and moves (#3485) |
|
||||
| 2026-11-05 | minor | Reconcile upstream form-control progress with the CJK baseline update |
|
||||
| 2026-11-02 | minor | Form controls; catalog 277 / 292 → 281 / 296 (Developer › Controls); progress log row |
|
||||
| 2026-11-02 | minor | Progress log: RefEdit reference boxes in dialogs |
|
||||
| 2026-11-02 | minor | Progress log: Python mode in the formula bar |
|
||||
@@ -271,6 +280,9 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-31 | minor | File-page text fields keep the view open when Esc cancels an edit (#3035) |
|
||||
| 2026-10-31 | minor | Progress log: Recently Used and Insert Function categories |
|
||||
| 2026-10-31 | minor | Progress log: Function Library |
|
||||
| 2026-10-31 | minor | Progress log: shared CJK/Latin baselines in cells, editors and window titles |
|
||||
| 2026-10-26 | minor | Progress log: Arabic bidi display and editing |
|
||||
| 2026-10-26 | minor | Progress log: Arabic font fallback |
|
||||
| 2026-10-25 | minor | Progress log: AutoSum over a selected row, and a selection's empty last row and column |
|
||||
| 2026-10-24 | minor | Macro interface on the Automate tab; progress log row |
|
||||
| 2026-10-24 | minor | Macro engine and `macro.*` commands; catalog 275 / 291 → 276 / 292 (View › Macros › Record Macro); progress log row |
|
||||
@@ -301,6 +313,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
| 2026-10-13 | trivial | Progress log: shared pictures written once on XLSX export (#985) |
|
||||
| 2026-10-13 | minor | Progress log: conditional-format lookups no longer scan the range for rules without statistics (#1098) |
|
||||
| 2026-10-12 | minor | Progress log: hidden-sheet navigation at workbook boundaries |
|
||||
| 2026-10-12 | minor | Numfmt cleanup: one built-in format table (gridcraft-xlsx), fewer allocations rendering numbers, dates and General, equality test against the previous output (#817); progress log row |
|
||||
| 2026-10-12 | minor | The control server requires an auth token and closes bad connections (#777); progress log row |
|
||||
| 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row |
|
||||
| 2026-10-12 | minor | Progress log: multi-area row/column delete (#758) |
|
||||
|
||||
@@ -14,6 +14,7 @@ name = "gridcraft-cli"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
gridcraft-sandbox = { workspace = true }
|
||||
gridcraft-engine = { workspace = true }
|
||||
gridcraft-mcp = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -214,7 +214,7 @@ pub fn main(args: &[String]) -> ExitCode {
|
||||
Err(_) => return usage(format!("--max-report needs a whole number, not `{n}`")),
|
||||
}
|
||||
}
|
||||
let Discovery { files, failures: mut reports } = collect_inputs(&a.positional);
|
||||
let Discovery { files, failures: mut reports, .. } = collect_inputs(&a.positional);
|
||||
reports.extend(files.iter().map(|p| {
|
||||
let path = p.to_string_lossy().into_owned();
|
||||
match gridcraft_engine::io::read_file(&path) {
|
||||
@@ -233,6 +233,9 @@ pub fn main(args: &[String]) -> ExitCode {
|
||||
#[derive(Default)]
|
||||
struct Discovery {
|
||||
files: Vec<PathBuf>,
|
||||
/// Identity (dev+inode) of each collected file, so a rescan reached by a shallower path doesn't add it twice.
|
||||
#[cfg(unix)]
|
||||
file_ids: std::collections::HashSet<(u64, u64)>,
|
||||
failures: Vec<FileReport>,
|
||||
}
|
||||
|
||||
@@ -257,11 +260,11 @@ impl Discovery {
|
||||
/// (`~$…`) are skipped.
|
||||
fn collect_inputs(inputs: &[String]) -> Discovery {
|
||||
let mut out = Discovery::default();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut seen = std::collections::HashMap::new();
|
||||
for i in inputs {
|
||||
let p = PathBuf::from(i);
|
||||
if p.is_dir() {
|
||||
if !walk(&p, 0, &mut out, &mut seen) {
|
||||
if !walk(&p, 0, false, &mut out, &mut seen) {
|
||||
break;
|
||||
}
|
||||
} else if !out.add_file(p) {
|
||||
@@ -277,19 +280,29 @@ fn collect_inputs(inputs: &[String]) -> Discovery {
|
||||
out
|
||||
}
|
||||
|
||||
/// Returns false only when the global file budget stops traversal.
|
||||
fn walk(dir: &Path, depth: usize, out: &mut Discovery, seen: &mut std::collections::HashSet<u64>) -> bool {
|
||||
// A directory is visited once by identity (dev+inode on unix): several links to the same
|
||||
// directory are aliases, not exponential branches of the scan. Other platforms keep the
|
||||
// depth cap only.
|
||||
/// Returns false only when the global file budget stops traversal. `seen` holds the shallowest
|
||||
/// depth each directory was scanned from; `rescan` is set below a directory reached again from a
|
||||
/// shallower path (its depth-limited subtree may now fit), where already collected files are skipped.
|
||||
#[cfg_attr(not(unix), allow(unused_mut))]
|
||||
fn walk(dir: &Path, depth: usize, mut rescan: bool, out: &mut Discovery, seen: &mut std::collections::HashMap<(u64, u64), usize>) -> bool {
|
||||
// A directory is visited once by identity (dev+inode on unix) unless reached from a shallower
|
||||
// path: several links to the same directory are aliases, not exponential branches of the scan
|
||||
// (each directory is scanned at most MAX_DEPTH + 1 times). Other platforms keep the depth cap only.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt as _;
|
||||
if let Ok(m) = std::fs::metadata(dir)
|
||||
&& let Some(id) = m.dev().checked_mul(1 << 60).and_then(|d| d.checked_add(m.ino()))
|
||||
&& !seen.insert(id)
|
||||
{
|
||||
return true;
|
||||
if let Ok(m) = std::fs::metadata(dir) {
|
||||
let identity = (m.dev(), m.ino());
|
||||
// A directory past the depth limit is not scanned, so it is not recorded: asked for
|
||||
// by name later, it is walked from depth 0.
|
||||
match seen.get(&identity) {
|
||||
Some(&shallowest) if shallowest <= depth => return true,
|
||||
Some(_) => rescan = true,
|
||||
None => {}
|
||||
}
|
||||
if depth <= MAX_DEPTH {
|
||||
seen.insert(identity, depth);
|
||||
}
|
||||
}
|
||||
}
|
||||
if depth > MAX_DEPTH {
|
||||
@@ -324,13 +337,20 @@ fn walk(dir: &Path, depth: usize, out: &mut Discovery, seen: &mut std::collectio
|
||||
}
|
||||
};
|
||||
if metadata.is_dir() {
|
||||
if !walk(&p, depth + 1, out, seen) {
|
||||
if !walk(&p, depth + 1, rescan, out, seen) {
|
||||
return false;
|
||||
}
|
||||
} else if p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("xlsx") || e.eq_ignore_ascii_case("xlsm")) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt as _;
|
||||
if !out.file_ids.insert((metadata.dev(), metadata.ino())) && rescan {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if !out.add_file(p) {
|
||||
return false;
|
||||
}
|
||||
} else if p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("xlsx") || e.eq_ignore_ascii_case("xlsm"))
|
||||
&& !out.add_file(p)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
|
||||
@@ -11,8 +11,8 @@ fn discovery_reports_a_directory_read_error() {
|
||||
let path = std::env::temp_dir().join(format!("gridcraft-check-not-a-directory-{}", std::process::id()));
|
||||
std::fs::write(&path, "not a directory").unwrap();
|
||||
let mut discovery = Discovery::default();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
assert!(walk(&path, 0, &mut discovery, &mut seen));
|
||||
let mut seen = std::collections::HashMap::new();
|
||||
assert!(walk(&path, 0, false, &mut discovery, &mut seen));
|
||||
assert!(discovery.files.is_empty());
|
||||
assert_eq!(discovery.failures.len(), 1);
|
||||
assert_eq!(discovery.failures[0].path, path.to_string_lossy());
|
||||
@@ -31,6 +31,47 @@ fn discovery_reports_omissions_only_after_the_file_limit() {
|
||||
assert!(discovery.failures[0].error.as_ref().unwrap().contains("file limit"));
|
||||
}
|
||||
|
||||
/// A new directory under the temp dir: `create_dir` fails on a name somebody else made first.
|
||||
#[cfg(unix)]
|
||||
fn fresh_dir(tag: &str) -> PathBuf {
|
||||
let mut n = 0u32;
|
||||
loop {
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-check-{tag}-{}-{n}", std::process::id()));
|
||||
match std::fs::create_dir(&dir) {
|
||||
Ok(()) => return dir,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => n += 1,
|
||||
Err(e) => panic!("{e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A directory named again by a deeper path, or by a link back to a parent, lists each workbook once.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn discovery_lists_each_workbook_once_whatever_path_reaches_it() {
|
||||
let dir = fresh_dir("once");
|
||||
let sub = dir.join("sub");
|
||||
std::fs::create_dir(&sub).unwrap();
|
||||
std::fs::write(dir.join("a.xlsx"), b"x").unwrap();
|
||||
std::fs::write(sub.join("b.xlsx"), b"x").unwrap();
|
||||
std::os::unix::fs::symlink(&dir, sub.join("loop")).unwrap();
|
||||
let names = |d: &Discovery| {
|
||||
let mut v: Vec<String> = d.files.iter().map(|p| p.file_name().unwrap().to_string_lossy().into_owned()).collect();
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
// The link back to the top is one directory, not another branch of the scan.
|
||||
let found = collect_inputs(&[dir.to_string_lossy().into_owned()]);
|
||||
assert_eq!(names(&found), ["a.xlsx", "b.xlsx"]);
|
||||
// `sub` named after its parent is reached from a shallower depth: scanned again, nothing listed twice.
|
||||
let found = collect_inputs(&[dir.to_string_lossy().into_owned(), sub.to_string_lossy().into_owned()]);
|
||||
assert_eq!(names(&found), ["a.xlsx", "b.xlsx"]);
|
||||
// Named first, the same `sub` is the shallowest path and the parent's scan skips it.
|
||||
let found = collect_inputs(&[sub.to_string_lossy().into_owned(), dir.to_string_lossy().into_owned()]);
|
||||
assert_eq!(names(&found), ["a.xlsx", "b.xlsx"]);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
fn at(a1: &str) -> CellRef {
|
||||
CellRef::parse(a1).unwrap()
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
//! gridcraft-cli functions [--json] [--search X] [--category C]
|
||||
//! gridcraft-cli mcp [--connect PORT] [--in FILE | --sample NAME]
|
||||
//! gridcraft-cli send <port> <method> [json]
|
||||
//! gridcraft-cli sandbox-check [--read PATH]... [--exec PATH]... [-- PROGRAM ARGS...]
|
||||
//! gridcraft-cli version | --version
|
||||
//! ```
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
@@ -30,6 +31,7 @@ macro_rules! outln {
|
||||
}
|
||||
|
||||
mod check;
|
||||
mod sandbox_check;
|
||||
|
||||
use gridcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
@@ -49,6 +51,8 @@ const USAGE: &str = "usage:
|
||||
gridcraft-cli functions [--json] [--search X] [--category C]
|
||||
gridcraft-cli mcp [--connect PORT] [--in FILE | --sample NAME]
|
||||
gridcraft-cli send <port> <method> [json]
|
||||
gridcraft-cli sandbox-check [--read PATH]... [--exec PATH]... [-- PROGRAM ARGS...]
|
||||
whether this system confines programs (no program: a confined self-test)
|
||||
gridcraft-cli version";
|
||||
|
||||
/// stdout went away. A reader that stopped early (a closed pipe) ends the program quietly; any
|
||||
@@ -62,6 +66,11 @@ fn stdout_failed(e: std::io::Error) -> ! {
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
// A confined launcher run (and its probes) comes first, before anything else starts.
|
||||
let raw: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
|
||||
if let Some(code) = gridcraft_sandbox::handle_args(&raw) {
|
||||
return ExitCode::from(u8::try_from(code).unwrap_or(1));
|
||||
}
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let rest = args.get(1..).unwrap_or_default();
|
||||
let r = match args.first().map(String::as_str) {
|
||||
@@ -79,6 +88,7 @@ fn main() -> ExitCode {
|
||||
Some("cat") => cat(rest),
|
||||
Some("run") => run(rest),
|
||||
Some("check") => return check::main(rest),
|
||||
Some("sandbox-check") => return sandbox_check::main(rest),
|
||||
Some("commands") => commands(rest),
|
||||
Some("functions") => functions(rest),
|
||||
Some("mcp") => mcp(rest),
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//! `gridcraft-cli sandbox-check [--read PATH]... [--exec PATH]... [-- PROGRAM ARGS...]`: whether
|
||||
//! this system confines programs, for a person or a reviewer checking a machine.
|
||||
//!
|
||||
//! Without a program it starts a confined copy of itself that tries what the sandbox must deny
|
||||
//! (reading outside the policy, writing, the network, starting a process) and what it must still
|
||||
//! allow (a thread, reading an allowed file), and prints one line per attempt. With a program it
|
||||
//! runs that program confined, with the given read and executable paths and the system's shared
|
||||
//! libraries, and passes its output and exit status through; its own lines then go to standard
|
||||
//! error. Every path is absolute. Exit status: 0 when every attempt is
|
||||
//! as required (or the program succeeded), 1 otherwise (a refused launch included), 2 when GridCraft
|
||||
//! has no sandbox for this kind of system.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use gridcraft_sandbox::{Canary, Policy, REFUSED_EXIT_CODE, REPORT_FLAG, Support};
|
||||
|
||||
pub fn main(args: &[String]) -> ExitCode {
|
||||
match run(args) {
|
||||
Ok(code) => code,
|
||||
Err(e) => {
|
||||
eprintln!("gridcraft-cli: sandbox-check: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<ExitCode, String> {
|
||||
let mut policy = Policy::new().with_system_libraries();
|
||||
let mut it = args.iter();
|
||||
let mut program: Option<(PathBuf, Vec<OsString>)> = None;
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"--read" => policy = policy.read(it.next().ok_or("--read needs a path")?),
|
||||
"--exec" => policy = policy.exec(it.next().ok_or("--exec needs a path")?),
|
||||
"--" => {
|
||||
let p = it.next().ok_or("a program follows `--`")?;
|
||||
program = Some((PathBuf::from(p), it.by_ref().map(OsString::from).collect()));
|
||||
}
|
||||
other => return Err(format!("unknown option `{other}`")),
|
||||
}
|
||||
}
|
||||
// With a program, its standard output is passed through untouched, so this command's own
|
||||
// lines go to standard error; the self-test's report is the output, on standard output.
|
||||
let report = program.is_none();
|
||||
let say = |line: String| {
|
||||
if report {
|
||||
outln!("{line}");
|
||||
} else {
|
||||
eprintln!("{line}");
|
||||
}
|
||||
};
|
||||
match gridcraft_sandbox::support() {
|
||||
Support::Available(how) => say(format!("sandbox: available ({how})")),
|
||||
Support::Unsupported(why) => {
|
||||
say(format!("sandbox: unsupported ({why})"));
|
||||
return Ok(ExitCode::from(2));
|
||||
}
|
||||
}
|
||||
let canary = Canary::create().map_err(|e| e.to_string())?;
|
||||
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
|
||||
let (program, program_args) = match program {
|
||||
Some((p, a)) => (p, a),
|
||||
// A confined copy of this program, reporting each attempt; it reads its own executable as
|
||||
// the allowed file.
|
||||
None => {
|
||||
let home = std::env::var_os("HOME").unwrap_or_default();
|
||||
(exe.clone(), vec![REPORT_FLAG.into(), canary.file().into(), exe.clone().into(), home])
|
||||
}
|
||||
};
|
||||
let policy = policy.exec(program.clone());
|
||||
let mut cmd = gridcraft_sandbox::launch_command(&policy, &canary, &program, &program_args).map_err(|e| e.to_string())?;
|
||||
let status = cmd.status().map_err(|e| format!("couldn't start the launcher: {e}"))?;
|
||||
drop(canary);
|
||||
match status.code() {
|
||||
Some(0) => {
|
||||
say(if report { "result: every attempt is as the sandbox requires" } else { "result: the program ran confined and succeeded" }.into());
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
Some(REFUSED_EXIT_CODE) => {
|
||||
// The launcher's refusal and a program's own exit share the code; the launcher's reason,
|
||||
// when it refused, is the `gridcraft-sandbox: refused:` line above.
|
||||
say(format!(
|
||||
"result: exit code {REFUSED_EXIT_CODE}: the launcher refused to run the program (its reason is above), or the program itself exited with {REFUSED_EXIT_CODE}"
|
||||
));
|
||||
Ok(ExitCode::FAILURE)
|
||||
}
|
||||
other => {
|
||||
say(format!(
|
||||
"result: {} ({})",
|
||||
if report { "some attempts are not as the sandbox requires" } else { "the program failed" },
|
||||
other.map_or("no exit code".into(), |c| format!("exit code {c}"))
|
||||
));
|
||||
Ok(ExitCode::FAILURE)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,6 +205,44 @@ fn check_reports_a_depth_limited_scan_while_checking_readable_files() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_directory_past_the_depth_limit_is_still_scanned_when_named_as_an_input() {
|
||||
let dir = tmpdir("check-depth-named");
|
||||
// The first folder past the limit (33 levels down): the scan from the top reports it unreached.
|
||||
let mut deep = dir.clone();
|
||||
for _ in 0..33 {
|
||||
deep.push("d");
|
||||
}
|
||||
std::fs::create_dir_all(&deep).unwrap();
|
||||
ok(&["run", "--out", deep.join("deep.xlsx").to_str().unwrap(), "--quiet"]);
|
||||
let out = run(&["check", dir.to_str().unwrap(), deep.to_str().unwrap(), "--json"]);
|
||||
let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
// The first scan reports the subtree it could not reach; the named directory is checked.
|
||||
assert_eq!(report["totals"]["opened"], 1, "{report}");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_directory_first_reached_at_the_depth_limit_is_completed_when_named_as_an_input() {
|
||||
let dir = tmpdir("check-depth-shallower");
|
||||
// The 32nd folder is scanned from the top, but its child (and the workbook in it) is past the limit.
|
||||
let mut limit = dir.clone();
|
||||
for _ in 0..32 {
|
||||
limit.push("d");
|
||||
}
|
||||
let child = limit.join("c");
|
||||
std::fs::create_dir_all(&child).unwrap();
|
||||
ok(&["run", "--out", limit.join("at_limit.xlsx").to_str().unwrap(), "--quiet"]);
|
||||
ok(&["run", "--out", child.join("below.xlsx").to_str().unwrap(), "--quiet"]);
|
||||
let out = run(&["check", dir.to_str().unwrap(), limit.to_str().unwrap(), "--json"]);
|
||||
let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
// Named from depth 0 the subtree is read: the workbook below the limit is checked, the one at it only once.
|
||||
assert_eq!(report["totals"]["opened"], 2, "{report}");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// A script line whose parameters are under a key `run` does not read used to run the command
|
||||
/// with no parameters at all, and the run still exited 0: three mistaken lines made Sheet2,
|
||||
/// Sheet3 and Sheet4 instead of the named sheets, and nothing said so (#2256).
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//! The crate's integration tests, one module per file, built as one test binary.
|
||||
mod cli;
|
||||
mod sandbox;
|
||||
mod text_to_columns_quotes;
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
//! The confined launcher, through the real binary: what a confined program can't do, what it
|
||||
//! still can, and that a launch whose check fails runs nothing. Linux and macOS confine; other
|
||||
//! systems report that they can't.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
|
||||
/// Whether this machine confines programs: a supported system whose self-test passes. On a Linux
|
||||
/// or macOS machine that reports no sandbox (another architecture, no `sandbox-exec`) or whose
|
||||
/// kernel doesn't enforce Landlock, the confinement tests have nothing to check and pass with a
|
||||
/// note. On CI (`CI` set) the machine must confine, so a failure there is never skipped.
|
||||
fn confines() -> bool {
|
||||
static CONFINES: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*CONFINES.get_or_init(|| {
|
||||
if !cfg!(any(target_os = "linux", target_os = "macos")) {
|
||||
return false;
|
||||
}
|
||||
let o = cli(&["sandbox-check"]);
|
||||
let out = text(&o);
|
||||
let missing = out.contains("sandbox: unsupported") || out.contains("Landlock is not enforced");
|
||||
if missing && std::env::var_os("CI").is_none() {
|
||||
eprintln!("this machine doesn't confine programs; skipping:\n{out}");
|
||||
return false;
|
||||
}
|
||||
true
|
||||
})
|
||||
}
|
||||
|
||||
fn cli(args: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_gridcraft-cli")).args(args).output().unwrap()
|
||||
}
|
||||
|
||||
fn text(o: &Output) -> String {
|
||||
format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))
|
||||
}
|
||||
|
||||
/// A directory of the test's own in the temporary directory, with one file.
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().canonicalize().unwrap().join(format!("gridcraft-sandbox-it-{name}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("allowed.txt"), "allowed text\n").unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// `cat` as this system names it.
|
||||
fn cat() -> &'static str {
|
||||
if Path::new("/bin/cat").exists() { "/bin/cat" } else { "/usr/bin/cat" }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_self_test_finds_every_denial() {
|
||||
// A home directory of the test's own, so the home-directory attempt always runs.
|
||||
let home = scratch("home");
|
||||
let o = Command::new(env!("CARGO_BIN_EXE_gridcraft-cli")).arg("sandbox-check").env("HOME", &home).output().unwrap();
|
||||
let out = text(&o);
|
||||
if !cfg!(any(target_os = "linux", target_os = "macos")) {
|
||||
assert_eq!(o.status.code(), Some(2), "{out}");
|
||||
assert!(out.contains("sandbox: unsupported"), "{out}");
|
||||
return;
|
||||
}
|
||||
if !confines() {
|
||||
return;
|
||||
}
|
||||
assert!(o.status.success(), "{out}");
|
||||
for line in [
|
||||
"denied: read the canary",
|
||||
"denied: change the canary's permissions",
|
||||
"denied: truncate the canary",
|
||||
"denied: write next to the canary",
|
||||
"denied: write in the temporary directory",
|
||||
"denied: TCP connection",
|
||||
"denied: start a process",
|
||||
"denied: signal another process",
|
||||
"denied: UDP socket",
|
||||
"denied: list the home directory",
|
||||
"works: start a thread",
|
||||
"works: read an allowed file",
|
||||
] {
|
||||
assert!(out.contains(line), "missing `{line}` in:\n{out}");
|
||||
}
|
||||
std::fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_confined_program_reads_only_what_it_is_given() {
|
||||
if !confines() {
|
||||
return;
|
||||
}
|
||||
let dir = scratch("read");
|
||||
let outside = scratch("outside");
|
||||
let d = dir.to_string_lossy().into_owned();
|
||||
let allowed = dir.join("allowed.txt").to_string_lossy().into_owned();
|
||||
let o = cli(&["sandbox-check", "--read", &d, "--", cat(), &allowed]);
|
||||
assert!(o.status.success(), "{}", text(&o));
|
||||
// The program's output is passed through untouched; this command's own lines go to stderr.
|
||||
assert_eq!(String::from_utf8_lossy(&o.stdout), "allowed text\n", "{}", text(&o));
|
||||
assert!(String::from_utf8_lossy(&o.stderr).contains("result: the program ran confined"), "{}", text(&o));
|
||||
// The same program, a file outside the policy: the read is refused and nothing is printed.
|
||||
let other = outside.join("allowed.txt").to_string_lossy().into_owned();
|
||||
let o = cli(&["sandbox-check", "--read", &d, "--", cat(), &other]);
|
||||
assert!(!o.status.success(), "{}", text(&o));
|
||||
assert!(!String::from_utf8_lossy(&o.stdout).contains("allowed text"), "{}", text(&o));
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
std::fs::remove_dir_all(outside).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_launch_whose_check_fails_runs_nothing() {
|
||||
// A run that says it is already confined and isn't finds the canary readable and must refuse.
|
||||
// On macOS that is the second stage started without `sandbox-exec`; on Linux the launcher never
|
||||
// uses that flag, and the same refusal must hold.
|
||||
let canary = scratch("canary");
|
||||
let echo = if Path::new("/bin/echo").exists() { "/bin/echo" } else { "/usr/bin/echo" };
|
||||
let probe = canary.join("allowed.txt").to_string_lossy().into_owned();
|
||||
// A dynamically linked `echo` needs the system's shared libraries and its loader.
|
||||
// The second stage takes the process the first looked at: here, the parent of what `cli` starts.
|
||||
let mut launch = vec![
|
||||
"--sandbox-confined".to_string(),
|
||||
std::process::id().to_string(),
|
||||
"--exec".into(),
|
||||
echo.into(),
|
||||
"--exec".into(),
|
||||
env!("CARGO_BIN_EXE_gridcraft-cli").into(),
|
||||
];
|
||||
for lib in ["/lib", "/lib64", "/usr/lib", "/usr/lib64"].into_iter().filter(|d| Path::new(d).exists()) {
|
||||
launch.extend(["--exec".to_string(), lib.into()]);
|
||||
}
|
||||
launch.extend(["--probe".to_string(), probe.clone(), "--".into(), echo.into(), "the program ran".into()]);
|
||||
let o = cli(&launch.iter().map(String::as_str).collect::<Vec<_>>());
|
||||
let out = text(&o);
|
||||
assert_eq!(o.status.code(), Some(97), "{out}");
|
||||
assert!(!out.contains("the program ran"), "{out}");
|
||||
assert!(out.contains("gridcraft-sandbox: refused"), "{out}");
|
||||
assert!(out.contains("read the canary"), "the check refused, not the arguments: {out}");
|
||||
// Without that process, the second stage has nothing to try a signal on.
|
||||
let without: Vec<&str> = std::iter::once("--sandbox-confined").chain(launch[2..].iter().map(String::as_str)).collect();
|
||||
let o = cli(&without);
|
||||
assert_eq!(o.status.code(), Some(97), "{}", text(&o));
|
||||
assert!(text(&o).contains("needs the process"), "{}", text(&o));
|
||||
// The normal path: the launcher confines itself first, and the program runs.
|
||||
if confines() {
|
||||
launch.splice(0..2, ["--sandbox-launch".to_string()]);
|
||||
// The launcher may be under a folder in the list, on either system.
|
||||
let mut by_folder = launch.clone();
|
||||
by_folder[4] = Path::new(env!("CARGO_BIN_EXE_gridcraft-cli")).parent().unwrap().to_string_lossy().into_owned();
|
||||
let o = cli(&by_folder.iter().map(String::as_str).collect::<Vec<_>>());
|
||||
assert!(o.status.success(), "{}", text(&o));
|
||||
assert!(text(&o).contains("the program ran"), "{}", text(&o));
|
||||
let o = cli(&launch.iter().map(String::as_str).collect::<Vec<_>>());
|
||||
let out = text(&o);
|
||||
assert!(o.status.success() && out.contains("the program ran"), "{out}");
|
||||
}
|
||||
// A canary inside an allowed path is refused before anything runs, everywhere.
|
||||
let dir = canary.to_string_lossy().into_owned();
|
||||
let o = cli(&["--sandbox-launch", "--read", &dir, "--exec", echo, "--probe", &probe, "--", echo, "the program ran"]);
|
||||
assert_eq!(o.status.code(), Some(97), "{}", text(&o));
|
||||
assert!(!text(&o).contains("the program ran"));
|
||||
std::fs::remove_dir_all(canary).unwrap();
|
||||
}
|
||||
|
||||
/// A Python found on this system: the interpreter to run confined, and its installation, the path it
|
||||
/// needs to read. `None` when there is no `python3`.
|
||||
fn system_python() -> Option<(String, String)> {
|
||||
// On macOS `/usr/bin/python3` is a stub that starts Xcode's Python, outside any read path given
|
||||
// here; an installed Python comes first.
|
||||
let candidates = if cfg!(target_os = "macos") {
|
||||
["/opt/homebrew/bin/python3", "/usr/local/bin/python3"]
|
||||
} else {
|
||||
["/usr/bin/python3", "/usr/local/bin/python3"]
|
||||
};
|
||||
let python = candidates.into_iter().find(|p| Path::new(p).exists())?;
|
||||
let prefix = Command::new(python).args(["-c", "import sys; print(sys.base_prefix)"]).output().unwrap();
|
||||
let prefix = String::from_utf8_lossy(&prefix.stdout).trim().to_string();
|
||||
// A framework build's `bin/python3` only starts `Python.app`'s interpreter as a new process,
|
||||
// which the sandbox refuses; the interpreter itself is run instead.
|
||||
let framework = Path::new(&prefix).join("Resources/Python.app/Contents/MacOS/Python");
|
||||
let real = if framework.exists() { framework } else { std::fs::canonicalize(python).unwrap() };
|
||||
Some((real.to_string_lossy().into_owned(), prefix))
|
||||
}
|
||||
|
||||
/// Confined, a program can't signal another process (this test, signal 0 delivering nothing), but can
|
||||
/// still signal itself and its own threads. On Linux, where the filter names them, it also can't
|
||||
/// change another process's limits or priority, or type into a terminal: no call here reaches a real
|
||||
/// terminal, since its standard input isn't one.
|
||||
#[test]
|
||||
fn a_confined_program_leaves_other_processes_alone() {
|
||||
if !confines() {
|
||||
return;
|
||||
}
|
||||
let Some((python, prefix)) = system_python() else {
|
||||
return;
|
||||
};
|
||||
let code = r#"
|
||||
import os, signal, sys, threading
|
||||
other = int(sys.argv[1])
|
||||
def refused(call, *args):
|
||||
try:
|
||||
call(*args)
|
||||
except PermissionError:
|
||||
return True
|
||||
except OSError as e:
|
||||
print("other error", call, args, e)
|
||||
return False
|
||||
assert refused(os.kill, other, 0), "a signal to another process"
|
||||
os.kill(os.getpid(), 0)
|
||||
seen = []
|
||||
signal.signal(signal.SIGUSR1, lambda *_: seen.append(1))
|
||||
signal.raise_signal(signal.SIGUSR1)
|
||||
worker = threading.Thread(target=lambda: signal.pthread_kill(threading.main_thread().ident, signal.SIGUSR1))
|
||||
worker.start()
|
||||
worker.join()
|
||||
assert len(seen) >= 1, "a signal to itself"
|
||||
if sys.platform == "linux":
|
||||
import ctypes, errno, fcntl, resource, termios
|
||||
libc = ctypes.CDLL(None, use_errno=True)
|
||||
assert libc.shmget(0, 4096, 0o600) == -1 and ctypes.get_errno() == errno.EPERM, "System V shared memory"
|
||||
assert libc.msgget(0, 0o600) == -1 and ctypes.get_errno() == errno.EPERM, "a System V message queue"
|
||||
assert libc.semget(0, 1, 0o600) == -1 and ctypes.get_errno() == errno.EPERM, "a System V semaphore"
|
||||
if hasattr(libc, "mq_open"):
|
||||
assert libc.mq_open(b"/gridcraft-check", os.O_RDONLY) == -1 and ctypes.get_errno() == errno.EPERM, "a POSIX message queue"
|
||||
# macOS answers these for the processes the profile lets it signal, which are only itself.
|
||||
assert refused(os.kill, -1, 0), "a signal to every process"
|
||||
assert refused(os.kill, 0, 0), "a signal to the process group"
|
||||
assert refused(resource.prlimit, other, resource.RLIMIT_NOFILE), "another process's limits"
|
||||
assert refused(os.setpriority, os.PRIO_PROCESS, other, 0), "another process's priority"
|
||||
assert refused(os.setpriority, os.PRIO_USER, 0, 0), "every process's priority"
|
||||
assert refused(os.sched_setaffinity, other, {0}), "another process's processors"
|
||||
assert refused(os.pidfd_open, other), "a handle on another process"
|
||||
resource.prlimit(0, resource.RLIMIT_NOFILE)
|
||||
resource.prlimit(os.getpid(), resource.RLIMIT_NOFILE)
|
||||
os.setpriority(os.PRIO_PROCESS, 0, os.getpriority(os.PRIO_PROCESS, 0))
|
||||
assert refused(fcntl.ioctl, 0, termios.TIOCSTI, b"x"), "typing into a terminal"
|
||||
assert refused(fcntl.ioctl, 0, termios.TIOCSTI | (1 << 32), b"x"), "the same with high bits set"
|
||||
assert refused(fcntl.ioctl, 0, 0x541C, b"x"), "TIOCLINUX"
|
||||
print("left alone")
|
||||
"#;
|
||||
let me = std::process::id().to_string();
|
||||
let o = cli(&["sandbox-check", "--read", &prefix, "--", &python, "-I", "-c", code, &me]);
|
||||
assert!(o.status.success(), "{}", text(&o));
|
||||
assert!(String::from_utf8_lossy(&o.stdout).contains("left alone"), "{}", text(&o));
|
||||
}
|
||||
|
||||
/// Confined, a program changes no file, whether the policy lets it read that file or not, by path or
|
||||
/// by a descriptor: not its contents, permissions, owner, extended attributes, times or flags.
|
||||
/// Skipped when there is no `python3`.
|
||||
#[test]
|
||||
fn a_confined_program_changes_no_file() {
|
||||
if !confines() {
|
||||
return;
|
||||
}
|
||||
let Some((python, prefix)) = system_python() else {
|
||||
return;
|
||||
};
|
||||
let code = r#"
|
||||
import errno, fcntl, os, struct, sys
|
||||
readable, outside = sys.argv[1:3]
|
||||
def refused(what, call, *args):
|
||||
try:
|
||||
call(*args)
|
||||
except PermissionError:
|
||||
return
|
||||
except OSError as e:
|
||||
sys.exit(f"{what}: another error, not a refusal: {e}")
|
||||
sys.exit(f"{what}: it worked")
|
||||
fd = os.open(readable, os.O_RDONLY)
|
||||
for target in (readable, outside, fd):
|
||||
refused(f"permissions of {target}", os.chmod, target, 0)
|
||||
refused(f"owner of {target}", os.chown, target, os.getuid(), os.getgid())
|
||||
refused(f"times of {target}", os.utime, target, (0, 0))
|
||||
if sys.platform == "linux":
|
||||
refused(f"setting an attribute of {target}", os.setxattr, target, "user.gridcraft", b"1")
|
||||
refused(f"removing an attribute of {target}", os.removexattr, target, "user.gridcraft")
|
||||
for path in (readable, outside):
|
||||
refused(f"truncate {path}", os.truncate, path, 0)
|
||||
refused(f"open {path} with O_TRUNC", os.open, path, os.O_RDONLY | os.O_TRUNC)
|
||||
refused(f"open {path} to write", os.open, path, os.O_WRONLY)
|
||||
# A descriptor opened to read can't be truncated either way; the file's contents are checked after.
|
||||
try:
|
||||
os.ftruncate(fd, 0)
|
||||
except OSError:
|
||||
pass
|
||||
if sys.platform == "linux":
|
||||
import ctypes
|
||||
libc = ctypes.CDLL(None, use_errno=True)
|
||||
# Newer calls, which the kernel running this may not have: the filter answers before it would.
|
||||
for name, number in (("fchmodat2", 452), ("setxattrat", 463), ("removexattrat", 466), ("file_setattr", 469)):
|
||||
ctypes.set_errno(0)
|
||||
result = libc.syscall(number, -100, readable.encode(), 0, 0)
|
||||
assert result == -1 and ctypes.get_errno() == errno.EPERM, f"{name}: {result}, {ctypes.get_errno()}"
|
||||
# On a descriptor opened only to read, these change the file's flags and attributes.
|
||||
refused("FS_IOC_SETFLAGS", fcntl.ioctl, fd, 0x40086602, struct.pack("l", 0))
|
||||
refused("FS_IOC_FSSETXATTR", fcntl.ioctl, fd, 0x401C5820, bytes(28))
|
||||
refused("FS_IOC_SET_ENCRYPTION_POLICY", fcntl.ioctl, fd, 0x800C6613, bytes(12))
|
||||
# What a program still needs: its own descriptors' flags, and a file of its own in memory.
|
||||
os.set_inheritable(fd, True)
|
||||
os.set_inheritable(fd, False)
|
||||
os.set_blocking(fd, False)
|
||||
own = os.memfd_create("gridcraft-check")
|
||||
os.ftruncate(own, 16)
|
||||
assert os.fstat(own).st_size == 16
|
||||
print("unchanged")
|
||||
"#;
|
||||
let inside = scratch("unchanged-inside");
|
||||
let elsewhere = scratch("unchanged-outside");
|
||||
let (readable, outside) = (inside.join("allowed.txt"), elsewhere.join("allowed.txt"));
|
||||
let state = |file: &Path| {
|
||||
let m = std::fs::metadata(file).unwrap();
|
||||
(std::fs::read(file).unwrap(), m.permissions(), m.modified().unwrap())
|
||||
};
|
||||
let before = (state(&readable), state(&outside));
|
||||
let d = inside.to_string_lossy().into_owned();
|
||||
let (r, x) = (readable.to_string_lossy().into_owned(), outside.to_string_lossy().into_owned());
|
||||
let o = cli(&["sandbox-check", "--read", &prefix, "--read", &d, "--", &python, "-I", "-c", code, &r, &x]);
|
||||
assert!(o.status.success(), "{}", text(&o));
|
||||
assert!(String::from_utf8_lossy(&o.stdout).contains("unchanged"), "{}", text(&o));
|
||||
assert_eq!((state(&readable), state(&outside)), before);
|
||||
std::fs::remove_dir_all(inside).unwrap();
|
||||
std::fs::remove_dir_all(elsewhere).unwrap();
|
||||
}
|
||||
|
||||
/// A Python found on this system starts and imports its standard library while confined, with
|
||||
/// read access to its installation only. Skipped when there is no `python3`.
|
||||
#[test]
|
||||
fn python_starts_confined() {
|
||||
if !confines() {
|
||||
return;
|
||||
}
|
||||
let Some((real, prefix)) = system_python() else {
|
||||
return;
|
||||
};
|
||||
let o =
|
||||
cli(&["sandbox-check", "--read", &prefix, "--", &real, "-I", "-c", "import json, math, datetime, decimal; print(json.dumps([math.pi > 3]))"]);
|
||||
assert!(o.status.success(), "{}", text(&o));
|
||||
assert!(String::from_utf8_lossy(&o.stdout).contains("[true]"), "{}", text(&o));
|
||||
// Confined Python can't open the network or a file outside its installation.
|
||||
let o = cli(&["sandbox-check", "--read", &prefix, "--", &real, "-I", "-c", "import socket; socket.create_connection(('127.0.0.1', 9), 2)"]);
|
||||
assert!(!o.status.success(), "{}", text(&o));
|
||||
assert!(text(&o).contains("PermissionError") || text(&o).contains("Operation not permitted"), "{}", text(&o));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_confined_program_inherits_no_secrets() {
|
||||
if !confines() {
|
||||
return;
|
||||
}
|
||||
let env = if Path::new("/usr/bin/env").exists() { "/usr/bin/env" } else { "/bin/env" };
|
||||
let o = Command::new(env!("CARGO_BIN_EXE_gridcraft-cli"))
|
||||
.args(["sandbox-check", "--", env])
|
||||
.env("GRIDCRAFT_CONTROL_TOKEN", "secret-token")
|
||||
.env("AWS_SECRET_ACCESS_KEY", "secret-key")
|
||||
.env("LANG", "en_US.UTF-8")
|
||||
.output()
|
||||
.unwrap();
|
||||
let out = text(&o);
|
||||
assert!(o.status.success(), "{out}");
|
||||
assert!(!out.contains("secret-"), "the environment reached the program:\n{out}");
|
||||
assert!(out.contains("LANG=en_US.UTF-8"), "the locale is kept:\n{out}");
|
||||
assert!(out.contains("PATH=/usr/bin:/bin"), "{out}");
|
||||
}
|
||||
|
||||
/// A descriptor the parent leaves open (here fd 9, opened by a shell on a file outside any policy)
|
||||
/// would survive `exec` with access the sandbox doesn't revoke, so the launcher closes it: the
|
||||
/// program runs, and can't read through it. (A desktop app's system libraries leave such files open,
|
||||
/// so refusing to run would block Python there.)
|
||||
#[test]
|
||||
fn an_inherited_descriptor_never_reaches_the_program() {
|
||||
if !confines() {
|
||||
return;
|
||||
}
|
||||
let outside = scratch("leak");
|
||||
let leaked = outside.join("allowed.txt").to_string_lossy().into_owned();
|
||||
let cli = env!("CARGO_BIN_EXE_gridcraft-cli");
|
||||
// The shell opens fd 9 (not close-on-exec) and then becomes the command, which inherits it. The
|
||||
// confined shell then tries to read through it, with a built-in: starting `cat` is refused. Not
|
||||
// fd 3: on macOS the system's libraries hold a socket of their own there under the sandbox.
|
||||
let bash = if cfg!(target_os = "macos") { "--exec /bin/bash" } else { "" };
|
||||
let program = "read line <&9 && echo \"leaked: $line\"; echo finished";
|
||||
let script = format!("exec 9<'{leaked}'; exec '{cli}' sandbox-check {bash} -- /bin/sh -c '{program}'");
|
||||
let o = Command::new("/bin/sh").args(["-c", &script]).output().unwrap();
|
||||
let out = text(&o);
|
||||
assert!(out.contains("finished"), "the program ran:\n{out}");
|
||||
assert!(!out.contains("leaked:"), "the program read through the descriptor:\n{out}");
|
||||
std::fs::remove_dir_all(outside).unwrap();
|
||||
}
|
||||
@@ -17,7 +17,9 @@ path = "src/main.rs"
|
||||
gridcraft-engine = { workspace = true }
|
||||
gridcraft-ui-egui = { workspace = true }
|
||||
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
|
||||
# The page command API (storytold/craft-libs#413): window.gridcraft over the control channel.
|
||||
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "aa84415bae3b765fef6b7ef42404f66ad1cff9d9", version = "0.1.0" }
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "aa84415bae3b765fef6b7ef42404f66ad1cff9d9", version = "0.1.0" }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
//! `window.gridcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) over the
|
||||
//! app's own control channel, the one the desktop control server drives (`docs/control-protocol.md`).
|
||||
//!
|
||||
//! The crate owns the JavaScript side (`request`, `execute`, `commands`, `docInspect`, `uiInspect`,
|
||||
//! `screenshot`, as promises with a timeout). This module is GridCraft's part: a [`Request`] that
|
||||
//! queues a [`ControlRequest`] for the app, which answers it on its next frame, and waits for the
|
||||
//! reply. A call the page gave up on is released ([`Pending`]), so the app skips it if it has not started it, instead of
|
||||
//! applying a command the page was told had failed.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicU8;
|
||||
use std::sync::mpsc::{Receiver, TryRecvError};
|
||||
|
||||
use gridcraft_ui_egui::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// How often a waiting call looks for the app's reply, in milliseconds (about a frame).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
const POLL_MS: i32 = 16;
|
||||
|
||||
/// A queued control request whose reply has not arrived. Dropping it before the reply (the page's
|
||||
/// timeout fired, or the call was abandoned) releases the request: the app skips it if it has not started it (one it has taken still finishes).
|
||||
struct Pending {
|
||||
reply: Receiver<Value>,
|
||||
state: Arc<AtomicU8>,
|
||||
answered: bool,
|
||||
}
|
||||
|
||||
impl Pending {
|
||||
/// `Some` once the app has answered or gone away; `None` while the request is still waiting.
|
||||
fn poll(&mut self) -> Option<Value> {
|
||||
match self.reply.try_recv() {
|
||||
Ok(reply) => {
|
||||
self.answered = true;
|
||||
Some(reply)
|
||||
}
|
||||
Err(TryRecvError::Empty) => None,
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
self.answered = true;
|
||||
Some(json!({"ok": false, "error": "the app dropped the request"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Pending {
|
||||
fn drop(&mut self) {
|
||||
if !self.answered {
|
||||
ControlRequest::abandon(&self.state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use web::install;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web {
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::channel;
|
||||
|
||||
use craft_web_api::methods::Methods;
|
||||
use craft_web_api::page::{PageApi, Request};
|
||||
use gridcraft_ui_egui::{ControlRequest, SheetApp};
|
||||
use serde_json::json;
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
|
||||
use super::{POLL_MS, Pending};
|
||||
|
||||
/// The app id in `window.<app>`.
|
||||
const APP: &str = "gridcraft";
|
||||
|
||||
/// Give the app its control queue and put `window.gridcraft` on the page. An existing
|
||||
/// `window.gridcraft` keeps the functions it already has.
|
||||
pub fn install(app: &mut SheetApp, ctx: egui::Context) {
|
||||
let (queue, requests) = channel::<ControlRequest>();
|
||||
app.control_rx = Some(requests);
|
||||
let request: Request = Rc::new(move |method, params| {
|
||||
let (control, reply) = ControlRequest::new(method, params);
|
||||
let mut pending = Pending { reply, state: control.state.clone(), answered: false };
|
||||
if queue.send(control).is_err() {
|
||||
pending.answered = true;
|
||||
return Box::pin(async { json!({"ok": false, "error": "the app is not running"}) });
|
||||
}
|
||||
ctx.request_repaint();
|
||||
Box::pin(async move {
|
||||
loop {
|
||||
if let Some(reply) = pending.poll() {
|
||||
return reply;
|
||||
}
|
||||
sleep(POLL_MS).await;
|
||||
}
|
||||
})
|
||||
});
|
||||
if let Err(e) = Rc::new(PageApi::new(APP, Methods::default(), request)).install() {
|
||||
log::warn!("couldn't install window.{APP}: {e:?}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn sleep(ms: i32) {
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
|
||||
}
|
||||
});
|
||||
let _ = JsFuture::from(promise).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// `ControlRequest`'s `state` values (`gridcraft_ui_egui::control`): the app has started it, or its
|
||||
/// sender gave up.
|
||||
const TAKEN: u8 = 1;
|
||||
const ABANDONED: u8 = 2;
|
||||
|
||||
fn pending() -> (Pending, ControlRequest) {
|
||||
let (control, reply) = ControlRequest::new("document.inspect", json!({}));
|
||||
(Pending { reply, state: control.state.clone(), answered: false }, control)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_call_the_page_gave_up_on_is_released_so_the_app_skips_it() {
|
||||
let (waiting, control) = pending();
|
||||
drop(waiting);
|
||||
assert_eq!(control.state.load(Ordering::SeqCst), ABANDONED);
|
||||
assert!(!control.take(), "the app must not run a request the page was told had failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_answered_call_is_not_released() {
|
||||
let (mut waiting, control) = pending();
|
||||
assert!(control.take());
|
||||
control.reply.send(json!({"ok": true, "result": 1})).unwrap_or_default();
|
||||
assert_eq!(waiting.poll(), Some(json!({"ok": true, "result": 1})));
|
||||
drop(waiting);
|
||||
assert_eq!(control.state.load(Ordering::SeqCst), TAKEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_the_app_never_runs_waits_until_it_is_dropped() {
|
||||
let (mut waiting, control) = pending();
|
||||
assert_eq!(waiting.poll(), None);
|
||||
drop(control);
|
||||
assert_eq!(waiting.poll().and_then(|r| r["ok"].as_bool()), Some(false), "the app went away");
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,16 @@
|
||||
//! workbook to open and stores what GridCraft saves (`host`). A page can host GridCraft in its
|
||||
//! own document (no iframe) with `data-host="page"` on the canvas, talking over DOM `CustomEvent`s
|
||||
//! on the canvas. `&author=` names the user for notes and comments in either host mode.
|
||||
//! `window.gridcraft` (`craft-web-api`) is the page command API in every mode: `request`,
|
||||
//! `execute`, `commands`, `docInspect`, `uiInspect` and `screenshot` over the control methods
|
||||
//! (`api`).
|
||||
//! `?chrome=none|notitlebar|nofiletab` leaves the title bar and/or the File tab undrawn for a host
|
||||
//! that embeds the sheet in its own window (see `Chrome`). Preferences (theme, interface size,
|
||||
//! editing options…) are kept in `localStorage` (`prefs`).
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod api;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod host;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
|
||||
@@ -99,6 +99,7 @@ pub fn start() {
|
||||
}
|
||||
bridge.post_ready();
|
||||
}
|
||||
crate::api::install(&mut app, cc.egui_ctx.clone());
|
||||
Ok(Box::new(WebShell { app, inbox, bridge: app_bridge, prefs }))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ workspace = true
|
||||
[dependencies]
|
||||
gridcraft-engine = { workspace = true }
|
||||
gridcraft-ui-egui = { workspace = true }
|
||||
gridcraft-sandbox = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "clipboard"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -34,7 +35,7 @@ winit = { version = "0.30", default-features = false, features = ["x11", "waylan
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
# Same craft-libs revision as the workspace's craft-update and craft-window (see the root
|
||||
# Cargo.toml).
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "aa84415bae3b765fef6b7ef42404f66ad1cff9d9" }
|
||||
craft-update = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
arboard = { workspace = true, features = ["image-data"] }
|
||||
|
||||
@@ -64,6 +64,9 @@ impl eframe::App for App {
|
||||
fn on_exit(&mut self) {
|
||||
print::finish_pending_jobs();
|
||||
save_prefs(&self.0);
|
||||
// A ready update installs once the process has exited. The macOS exit below skips
|
||||
// dropping the app, so the helper is started here.
|
||||
self.0.start_pending_update();
|
||||
// macOS (#117): end the process here, before eframe tears the window down. AccessKit's
|
||||
// macOS adapter subclasses the window's content view and restores its class on teardown;
|
||||
// AppKit's Touch Bar support observes that view (KVO), so in the next display cycle
|
||||
@@ -238,6 +241,12 @@ fn icon() -> Option<egui::IconData> {
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result<()> {
|
||||
// A confined launcher run (`gridcraft_sandbox::launch_command`) comes before everything else:
|
||||
// it opens no window, writes no log, and becomes the program it confines.
|
||||
let raw: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
|
||||
if let Some(code) = gridcraft_sandbox::handle_args(&raw) {
|
||||
std::process::exit(code);
|
||||
}
|
||||
// First, so the panic hook and every start-up warning are recorded (`logging`).
|
||||
let logger = logging::install();
|
||||
// Commands catch their panics (`Session::execute`), but the default hook only prints to
|
||||
|
||||
@@ -33,7 +33,8 @@ sync_github_name = false
|
||||
|
||||
[people.vangel76]
|
||||
real_name = "Valentin Klein"
|
||||
sync_github_name = true
|
||||
display_name = "vangel76"
|
||||
sync_github_name = false
|
||||
|
||||
[people.MacJedi42]
|
||||
sync_github_name = true
|
||||
@@ -109,3 +110,6 @@ sync_github_name = true
|
||||
|
||||
[people.maxm11]
|
||||
sync_github_name = true
|
||||
|
||||
[people.RockerHX]
|
||||
sync_github_name = true
|
||||
|
||||
+221
-8
@@ -39,6 +39,15 @@ const MAX_DEPTH: usize = 400;
|
||||
/// every legitimate call; hostile work that grows past one dimension (a LAMBDA that recurses
|
||||
/// or nest `MAP` into `MAP`) then stops at the same bound instead of squaring.
|
||||
const LAMBDA_FUEL: u64 = MAX_CELLS;
|
||||
/// Work one cell's formula may do: one unit per expression evaluated, per cell read from a
|
||||
/// reference, per array value handed to a function, per pair of areas intersected and per
|
||||
/// character of a defined name's formula parsed where the name is used. Each limit
|
||||
/// above bounds one kind of step, but a compact formula could still repeat bounded steps
|
||||
/// exponentially often (defined names that each use the one before twice, a computed criterion
|
||||
/// whose database function reruns it, an aggregate over a large array inside every LAMBDA call).
|
||||
/// Past this the formula is `#CALC!`. It is far above what a formula that reads every cell of a
|
||||
/// full-size array a few times needs.
|
||||
const EVAL_WORK: u64 = 16 * MAX_CELLS;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct Area {
|
||||
@@ -115,6 +124,12 @@ pub trait Host {
|
||||
fn visit_range(&mut self, _sheet: usize, _range: RangeRef, _f: &mut dyn FnMut(&Value) -> bool) -> bool {
|
||||
false
|
||||
}
|
||||
/// `(sum, count)` of the numbers in a block that holds nothing but constants (no formula,
|
||||
/// error or spilled value), added row-major from `0.0` exactly as `visit_range` would hand
|
||||
/// them over. `None`: the block isn't like that, or this host can't tell.
|
||||
fn range_sum(&mut self, _sheet: usize, _range: RangeRef) -> Option<(f64, u64)> {
|
||||
None
|
||||
}
|
||||
/// While a formula is calculated on demand inside another one: `stack_position()` where the
|
||||
/// outermost one started, which the stack budget is counted from.
|
||||
fn stack_top(&self) -> Option<usize> {
|
||||
@@ -139,6 +154,16 @@ pub struct Evaluator<'h> {
|
||||
/// runs out of work after the same number of calls that a legitimately wide one could
|
||||
/// make, instead of an amount of work that squares with array nesting (#2382).
|
||||
fuel: u64,
|
||||
/// Work this cell still has left (see [`EVAL_WORK`]), shared by every branch of the formula
|
||||
/// and by the formulas it computes on the way (names, computed criteria).
|
||||
work: u64,
|
||||
/// A spend found too little work left. A spend that leaves exactly nothing is fine, so this
|
||||
/// can't be told from `work` alone; a database function checks it after its computed
|
||||
/// criteria ran, whose failures it can't otherwise see.
|
||||
exhausted: bool,
|
||||
/// Computed criteria being evaluated, outermost first: one that is reached again from inside
|
||||
/// itself counts as not met instead of repeating its work at every level.
|
||||
active_criteria: Vec<(usize, CellRef)>,
|
||||
}
|
||||
|
||||
struct FnCtx<'a, 'h> {
|
||||
@@ -168,6 +193,9 @@ impl Ctx for FnCtx<'_, '_> {
|
||||
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))?;
|
||||
if self.ev.active_criteria.contains(&(area.sheet, at)) {
|
||||
return Some(false);
|
||||
}
|
||||
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)
|
||||
@@ -187,12 +215,24 @@ impl Ctx for FnCtx<'_, '_> {
|
||||
calls: self.ev.calls,
|
||||
stack_top: self.ev.stack_top,
|
||||
fuel: self.ev.fuel,
|
||||
work: self.ev.work,
|
||||
exhausted: false,
|
||||
active_criteria: std::mem::take(&mut self.ev.active_criteria),
|
||||
};
|
||||
ev.active_criteria.push((area.sheet, at));
|
||||
let v = matches!(ev.value(&shifted).scalar().to_bool(), Ok(true));
|
||||
// The child's spend counts against this cell's budget.
|
||||
self.ev.fuel = ev.fuel;
|
||||
self.ev.work = ev.work;
|
||||
self.ev.exhausted |= ev.exhausted;
|
||||
let mut active = std::mem::take(&mut ev.active_criteria);
|
||||
active.pop();
|
||||
self.ev.active_criteria = active;
|
||||
Some(v)
|
||||
}
|
||||
fn exhausted(&self) -> bool {
|
||||
self.ev.exhausted
|
||||
}
|
||||
fn has_lambda(&self) -> bool {
|
||||
self.lambda.is_some()
|
||||
}
|
||||
@@ -210,7 +250,42 @@ impl Ctx for FnCtx<'_, '_> {
|
||||
|
||||
impl<'h> Evaluator<'h> {
|
||||
pub fn new(host: &'h mut dyn Host, sheet: usize, at: CellRef) -> Self {
|
||||
Evaluator { host, sheet, at, env: Vec::new(), depth: 0, calls: 0, stack_top: 0, fuel: LAMBDA_FUEL }
|
||||
Evaluator {
|
||||
host,
|
||||
sheet,
|
||||
at,
|
||||
env: Vec::new(),
|
||||
depth: 0,
|
||||
calls: 0,
|
||||
stack_top: 0,
|
||||
fuel: LAMBDA_FUEL,
|
||||
work: EVAL_WORK,
|
||||
exhausted: false,
|
||||
active_criteria: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Spends `units` of this cell's work; `false` (and nothing left) when there isn't enough.
|
||||
pub(crate) fn spend(&mut self, units: u64) -> bool {
|
||||
match self.work.checked_sub(units) {
|
||||
Some(w) => {
|
||||
self.work = w;
|
||||
true
|
||||
}
|
||||
None => {
|
||||
self.work = 0;
|
||||
self.exhausted = true;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Charges the values of an array (one unit per value; one for anything else).
|
||||
pub(crate) fn spend_on(&mut self, v: &Value) -> bool {
|
||||
match v {
|
||||
Value::Array(a) => self.spend(a.data.len() as u64),
|
||||
_ => self.spend(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluates to a value (references are dereferenced; multi-cell → array).
|
||||
@@ -260,18 +335,27 @@ impl<'h> Evaluator<'h> {
|
||||
}
|
||||
// A reference keeps its size, so the array has its shape and its blanks count
|
||||
// (`=A1:A10`, `SUMPRODUCT(--(A1:A10=""))`).
|
||||
let range = self.read_range(a);
|
||||
let (h, w) = (range.height() as usize, range.width() as usize);
|
||||
if (h as u64) * (w as u64) > MAX_CELLS {
|
||||
return Value::Error(CellError::Num);
|
||||
}
|
||||
if !self.spend((h as u64) * (w as u64)) {
|
||||
return Value::Error(CellError::Calc);
|
||||
}
|
||||
let data = self.host.range_values(a.sheet, range);
|
||||
Array::new(h, w, data).map(Value::from).unwrap_or(Value::Error(CellError::Value))
|
||||
}
|
||||
|
||||
/// The block that reading `a` gives: `a` itself, or when it is large (or runs to the sheet's
|
||||
/// edge) the part inside the sheet's used range.
|
||||
pub(crate) fn read_range(&mut self, a: &Area) -> RangeRef {
|
||||
let cells = u64::from(a.range.height()) * u64::from(a.range.width());
|
||||
// `A:A`, `1:1` and `A2:A1048576` run to the edge of the sheet: they stand for "the rest of
|
||||
// the column", not for a block of a chosen size.
|
||||
let to_edge = a.range.end.row == MAX_ROWS - 1 || a.range.end.col == MAX_COLS - 1;
|
||||
let full_size = cells <= FULL_SIZE_CELLS || (!to_edge && cells <= FULL_SIZE_BOUNDED_CELLS);
|
||||
let range = if full_size { a.range } else { self.trim(a) };
|
||||
let (h, w) = (range.height() as usize, range.width() as usize);
|
||||
if (h as u64) * (w as u64) > MAX_CELLS {
|
||||
return Value::Error(CellError::Num);
|
||||
}
|
||||
let data = self.host.range_values(a.sheet, range);
|
||||
Array::new(h, w, data).map(Value::from).unwrap_or(Value::Error(CellError::Value))
|
||||
if full_size { a.range } else { self.trim(a) }
|
||||
}
|
||||
|
||||
/// Trims the bottom/right of an area to the sheet's used range (keeps the top-left so
|
||||
@@ -304,6 +388,9 @@ impl<'h> Evaluator<'h> {
|
||||
self.host.stack_exhausted();
|
||||
return err(CellError::Num);
|
||||
}
|
||||
if !self.spend(1) {
|
||||
return err(CellError::Calc);
|
||||
}
|
||||
self.depth += 1;
|
||||
if self.depth > MAX_DEPTH {
|
||||
self.depth -= 1;
|
||||
@@ -376,6 +463,11 @@ impl<'h> Evaluator<'h> {
|
||||
if let Some(def) = wb.name(bare, scope_sheet) {
|
||||
let text = def.formula.clone();
|
||||
let scope = def.scope.unwrap_or(self.sheet);
|
||||
// Each use parses and evaluates the definition again: names that each use the one
|
||||
// before twice double the work per name, so it is charged.
|
||||
if !self.spend(text.len() as u64) {
|
||||
return err(CellError::Calc);
|
||||
}
|
||||
return match gridcraft_formula::parse_name(&text) {
|
||||
Ok(expr) => {
|
||||
// Relative references in a name are relative to A1: used here, they move by
|
||||
@@ -537,6 +629,11 @@ impl<'h> Evaluator<'h> {
|
||||
if r.height() == 1 && r.start.col <= self.at.col && self.at.col <= r.end.col {
|
||||
return Some(CellRef::new(r.start.row, self.at.col));
|
||||
}
|
||||
// A 2-D range intersects on both the formula's row and column (observed in Excel, on
|
||||
// another sheet too); one that includes the formula's own cell is a circular reference.
|
||||
if r.start.row <= self.at.row && self.at.row <= r.end.row && r.start.col <= self.at.col && self.at.col <= r.end.col {
|
||||
return Some(self.at);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -568,6 +665,10 @@ impl<'h> Evaluator<'h> {
|
||||
let (x, y) = (self.eval(a), self.eval(b));
|
||||
match (x, y) {
|
||||
(Ev::R(mut xa), Ev::R(ya)) => {
|
||||
// A union reused through LET doubles at each step: its areas count.
|
||||
if !self.spend((xa.len() as u64).saturating_add(ya.len() as u64)) {
|
||||
return err(CellError::Calc);
|
||||
}
|
||||
xa.extend(ya);
|
||||
Ev::R(xa)
|
||||
}
|
||||
@@ -579,6 +680,11 @@ impl<'h> Evaluator<'h> {
|
||||
let (x, y) = (self.eval(a), self.eval(b));
|
||||
match (x, y) {
|
||||
(Ev::R(xa), Ev::R(ya)) => {
|
||||
// Every pair is looked at, and each may be kept: charged before the loop,
|
||||
// so unions squared through LET can't build an enormous list.
|
||||
if !self.spend((xa.len() as u64).saturating_mul(ya.len() as u64)) {
|
||||
return err(CellError::Calc);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for p in &xa {
|
||||
for q in &ya {
|
||||
@@ -672,6 +778,113 @@ mod fuel_tests {
|
||||
ev.value(&gridcraft_formula::parser::parse(src).unwrap_or_else(|e| panic!("{e}")))
|
||||
}
|
||||
|
||||
/// Evaluates `src` in `wb` with `work` units of work, and the work left.
|
||||
fn eval_work(wb: Workbook, work: u64, src: &str) -> (Value, u64) {
|
||||
let mut host = Empty { wb };
|
||||
let mut ev = Evaluator::new(&mut host, 0, CellRef::new(0, 0));
|
||||
ev.work = work;
|
||||
let v = ev.value(&gridcraft_formula::parser::parse(src).unwrap_or_else(|e| panic!("{e}")));
|
||||
(v, ev.work)
|
||||
}
|
||||
|
||||
fn is_calc(v: &Value) -> bool {
|
||||
matches!(v, Value::Error(CellError::Calc))
|
||||
}
|
||||
|
||||
/// Serves the workbook's stored values, for database functions to read.
|
||||
struct Stored {
|
||||
wb: Workbook,
|
||||
}
|
||||
impl Host for Stored {
|
||||
fn workbook(&self) -> &Workbook {
|
||||
&self.wb
|
||||
}
|
||||
fn cell_value(&mut self, sheet: usize, c: CellRef) -> Value {
|
||||
self.wb.sheet(sheet).and_then(|s| s.cell(c)).map_or(Value::Empty, |cell| cell.value.clone())
|
||||
}
|
||||
fn now_serial(&self) -> f64 {
|
||||
0.0
|
||||
}
|
||||
fn random(&mut self) -> f64 {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// A computed criterion that runs out of work counts as not met for its record, which the
|
||||
/// database function can't see: it must answer #CALC!, not a count that leaves records out.
|
||||
#[test]
|
||||
fn a_computed_criterion_out_of_work_makes_the_database_function_calc() {
|
||||
use gridcraft_model::{Cell, Formula};
|
||||
let mut wb = Workbook::new();
|
||||
let name = |name: String, formula: String| gridcraft_model::DefinedName { name, scope: None, formula, comment: String::new(), hidden: false };
|
||||
wb.names.push(name("Step0".into(), "1".into()));
|
||||
for i in 1..=60 {
|
||||
wb.names.push(name(format!("Step{i}"), format!("Step{}+Step{}", i - 1, i - 1)));
|
||||
}
|
||||
let at = |s: &str| CellRef::parse(s).unwrap();
|
||||
let sheet = wb.sheet_mut(0).unwrap();
|
||||
for (r, (n, age)) in [("name", Value::text("age")), ("a", Value::number(1.0)), ("b", Value::number(2.0))].into_iter().enumerate() {
|
||||
sheet.cells.set(at(&format!("A{}", r + 1)), Cell::value(Value::text(n)));
|
||||
sheet.cells.set(at(&format!("B{}", r + 1)), Cell::value(age));
|
||||
}
|
||||
sheet.cells.set(at("D1"), Cell::value(Value::text("Formula")));
|
||||
let host_wb = |criterion: &str| {
|
||||
let mut wb = wb.clone();
|
||||
// A criteria cell is read by its value first (a blank one matches everything): give it
|
||||
// the value a recalculation would have stored.
|
||||
let mut cell = Cell::formula(Formula::new(criterion));
|
||||
cell.value = Value::Bool(true);
|
||||
wb.sheet_mut(0).unwrap().cells.set(at("D2"), cell);
|
||||
wb
|
||||
};
|
||||
let run = |criterion: &str| {
|
||||
let mut host = Stored { wb: host_wb(criterion) };
|
||||
let mut ev = Evaluator::new(&mut host, 0, CellRef::new(9, 9));
|
||||
ev.work = 100_000;
|
||||
ev.value(&gridcraft_formula::parser::parse("=DCOUNT(A1:B3,\"age\",D1:D2)").unwrap())
|
||||
};
|
||||
assert_eq!(run("=B2>0"), Value::number(2.0), "a cheap criterion counts every record");
|
||||
assert!(is_calc(&run("=Step60>0")), "{:?}", run("=Step60>0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_that_double_at_each_step_run_out_of_work() {
|
||||
// Step1 = Step0+Step0, Step2 = Step1+Step1, ...: Step60 would evaluate Step0 2^60 times.
|
||||
let mut wb = Workbook::default();
|
||||
let name = |name: String, formula: String| gridcraft_model::DefinedName { name, scope: None, formula, comment: String::new(), hidden: false };
|
||||
wb.names.push(name("Step0".into(), "1".into()));
|
||||
for i in 1..=60 {
|
||||
wb.names.push(name(format!("Step{i}"), format!("Step{}+Step{}", i - 1, i - 1)));
|
||||
}
|
||||
let (v, _) = eval_work(wb.clone(), EVAL_WORK, "=Step5");
|
||||
assert_eq!(v, Value::number(32.0));
|
||||
let (v, left) = eval_work(wb, 100_000, "=Step60");
|
||||
assert!(is_calc(&v), "{v:?}");
|
||||
assert_eq!(left, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intersections_of_unions_are_charged_per_pair() {
|
||||
let wb = Workbook::default();
|
||||
// 4 x 4 pairs, 4 of which meet.
|
||||
let src = "=AREAS((A1,B1,C1,D1) (A1,B1,C1,D1))";
|
||||
let (v, _) = eval_work(wb.clone(), EVAL_WORK, src);
|
||||
assert_eq!(v, Value::number(4.0));
|
||||
let (v, _) = eval_work(wb, 20, src);
|
||||
assert!(is_calc(&v), "{v:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_aggregate_inside_every_lambda_call_spends_work() {
|
||||
let wb = Workbook::default();
|
||||
// 100 calls, each summing the 10,000 values of a captured array: about a million units.
|
||||
let src = "=LET(big,SEQUENCE(10000),SUM(MAP(SEQUENCE(100),LAMBDA(x,SUM(big)))))";
|
||||
let (v, _) = eval_work(wb.clone(), 2_000_000, src);
|
||||
assert_eq!(v, Value::number(100.0 * 50_005_000.0));
|
||||
let (v, _) = eval_work(wb, 200_000, src);
|
||||
assert!(is_calc(&v), "{v:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lambda_calls_spend_fuel_and_run_out_quietly() {
|
||||
// Three applications, one charge each: 3 inside the budget. (A nested MAP wouldn't do
|
||||
|
||||
@@ -52,13 +52,16 @@ impl<'h> Evaluator<'h> {
|
||||
"ISFORMULA" | "FORMULATEXT" => {
|
||||
let Some(Ev::R(a)) = args.first().map(|a| self.eval(a)) else { return err(CellError::NA) };
|
||||
let Some(a) = a.first() else { return err(CellError::NA) };
|
||||
let f = self.host.workbook().sheet(a.sheet).and_then(|s| s.cell(a.range.start)).and_then(|c| c.formula.clone());
|
||||
let wb = self.host.workbook();
|
||||
let f = wb.sheet(a.sheet).and_then(|s| s.cell(a.range.start)).and_then(|c| c.formula.clone());
|
||||
// A formula formatted Hidden on a protected sheet stays hidden from FORMULATEXT too.
|
||||
let hidden = wb.sheet(a.sheet).is_some_and(|s| s.is_protected() && wb.styles.get(s.style_id(a.range.start)).protection.hidden);
|
||||
if name == "ISFORMULA" {
|
||||
Ev::V(Value::Bool(f.is_some()))
|
||||
} else {
|
||||
match f {
|
||||
Some(f) => Ev::V(Value::text(format!("={}", f.text))),
|
||||
None => err(CellError::NA),
|
||||
Some(f) if !hidden => Ev::V(Value::text(format!("={}", f.text))),
|
||||
_ => err(CellError::NA),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -203,9 +206,9 @@ impl<'h> Evaluator<'h> {
|
||||
let index = self.value(&args[0]);
|
||||
if let Value::Array(_) = index {
|
||||
let Some(spec) = gridcraft_functions::lookup("CHOOSE") else { return err(CellError::Name) };
|
||||
let mut evaluated = vec![Arg { value: index, from_ref: false }];
|
||||
let mut evaluated = vec![Arg::val(index)];
|
||||
for argument in &args[1..] {
|
||||
evaluated.push(Arg { value: self.value(argument), from_ref: false });
|
||||
evaluated.push(Arg::val(self.value(argument)));
|
||||
}
|
||||
let mut context = FnCtx { ev: self, criteria: None, lambda: None };
|
||||
return gridcraft_functions::call(spec, &evaluated, &mut context).pipe(Ev::V);
|
||||
@@ -351,10 +354,25 @@ impl<'h> Evaluator<'h> {
|
||||
}
|
||||
ev => self.deref(ev),
|
||||
};
|
||||
evaluated.push(Arg { value, from_ref });
|
||||
// Only an argument left out (`f(a,,b)`) is omitted; one that computes a blank is given.
|
||||
let blank_value = !from_ref && value == Value::Empty && lambda_at != Some(i) && !matches!(a, Expr::Missing);
|
||||
evaluated.push(Arg { value, from_ref, blank_value });
|
||||
}
|
||||
// The function may go through every value it is given (a SUM inside each call of a
|
||||
// LAMBDA over a large array): the values count against the cell's work.
|
||||
let values = evaluated.iter().fold(0u64, |n, a| n.saturating_add(if let Value::Array(x) = &a.value { x.data.len() as u64 } else { 1 }));
|
||||
if !self.spend(values) {
|
||||
return err(CellError::Calc);
|
||||
}
|
||||
let database = criteria.is_some();
|
||||
let mut ctx = FnCtx { ev: self, criteria, lambda };
|
||||
gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V)
|
||||
let v = gridcraft_functions::call(spec, &evaluated, &mut ctx);
|
||||
// A computed criterion that ran out of work counted as not met: the result leaves
|
||||
// records out.
|
||||
if database && self.exhausted {
|
||||
return err(CellError::Calc);
|
||||
}
|
||||
Ev::V(v)
|
||||
}
|
||||
|
||||
/// Performs aggregation operations without copying its ranges: blocks are folded over the stored
|
||||
@@ -383,6 +401,21 @@ impl<'h> Evaluator<'h> {
|
||||
/// Adds one area to `acc`; `Err` is the result the function ends with.
|
||||
fn aggregate_area(&mut self, kind: Agg, a: &Area, acc: &mut Acc) -> Result<(), Ev> {
|
||||
if !a.range.is_single() {
|
||||
// Adding the block's remembered sum to anything but +0.0 would add in another order.
|
||||
// COUNT never reads the sum, so it may reuse every block.
|
||||
if (kind == Agg::Count || acc.sum.to_bits() == 0)
|
||||
&& let Some((sum, count)) = self.host.range_sum(a.sheet, a.range)
|
||||
{
|
||||
acc.sum = sum;
|
||||
acc.count = acc.count.saturating_add(count);
|
||||
return Ok(());
|
||||
}
|
||||
// Folded without building an array, but still walked: the part inside the used
|
||||
// range counts against the cell's work.
|
||||
let r = self.read_range(a);
|
||||
if !self.spend(u64::from(r.height()) * u64::from(r.width())) {
|
||||
return Err(err(CellError::Calc));
|
||||
}
|
||||
let mut error = None;
|
||||
let walked = self.host.visit_range(a.sheet, a.range, &mut |v| match acc.add_cell(kind, v) {
|
||||
Ok(()) => true,
|
||||
@@ -402,10 +435,13 @@ impl<'h> Evaluator<'h> {
|
||||
/// Adds one argument's value by calling `SUM` and `COUNT` on it, so coercion and error rules
|
||||
/// stay in one place (`AVERAGE` of an argument is its `SUM` over its `COUNT`).
|
||||
fn aggregate_value(&mut self, kind: Agg, value: Value, from_ref: bool, acc: &mut Acc) -> Result<(), Ev> {
|
||||
if !from_ref && !self.spend_on(&value) {
|
||||
return Err(err(CellError::Calc));
|
||||
}
|
||||
let (Some(sum), Some(count)) = (gridcraft_functions::lookup("SUM"), gridcraft_functions::lookup("COUNT")) else {
|
||||
return Err(err(CellError::Name));
|
||||
};
|
||||
let args = [Arg { value, from_ref }];
|
||||
let args = [Arg { value, from_ref, blank_value: false }];
|
||||
if kind != Agg::Count {
|
||||
match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self, criteria: None, lambda: None }) {
|
||||
Value::Number(n) => acc.sum += n,
|
||||
|
||||
@@ -118,11 +118,29 @@ impl<'h> Evaluator<'h> {
|
||||
for c in 0..w {
|
||||
let pos = (num(&Some(ra.get_broadcast(r, c))), num(&Some(ca.get_broadcast(r, c))));
|
||||
let v = match pos {
|
||||
// A position that picks several cells is #CALC!. That is decided from the
|
||||
// shape: reading the block first, for every position, let a short formula
|
||||
// read one large range over and over only to discard it.
|
||||
(Ok(row), Ok(col)) if index_picks_several_values(&src, row, col, two) => Value::Error(CellError::Calc),
|
||||
(Ok(row), Ok(col)) => {
|
||||
let one = self.index_one(src.clone(), row, col, area_n, two);
|
||||
match self.deref(one) {
|
||||
Value::Array(a) if a.rows * a.cols > 1 => Value::Error(CellError::Calc),
|
||||
v => v.scalar(),
|
||||
let cells = match &one {
|
||||
Ev::R(areas) => match areas.as_slice() {
|
||||
[a] => self.area_cells(a),
|
||||
_ => 0,
|
||||
},
|
||||
_ => 0,
|
||||
};
|
||||
if cells > MAX_CELLS {
|
||||
// As reading it would answer.
|
||||
Value::Error(CellError::Num)
|
||||
} else if cells > 1 {
|
||||
Value::Error(CellError::Calc)
|
||||
} else {
|
||||
match self.deref(one) {
|
||||
Value::Array(a) if a.rows * a.cols > 1 => Value::Error(CellError::Calc),
|
||||
v => v.scalar(),
|
||||
}
|
||||
}
|
||||
}
|
||||
(Err(e), _) | (_, Err(e)) => Value::Error(e),
|
||||
@@ -139,6 +157,16 @@ impl<'h> Evaluator<'h> {
|
||||
self.index_one(src, row, col, area_n, two)
|
||||
}
|
||||
|
||||
/// Cells that reading `a` gives: its own size, or for a large block the part inside the
|
||||
/// sheet's used range (what [`Evaluator::area_value`] reads).
|
||||
fn area_cells(&mut self, a: &Area) -> u64 {
|
||||
if a.range.is_single() {
|
||||
return 1;
|
||||
}
|
||||
let r = self.read_range(a);
|
||||
u64::from(r.height()) * u64::from(r.width())
|
||||
}
|
||||
|
||||
/// INDEX for one row and column position (0 = the whole column or row).
|
||||
fn index_one(&mut self, src: Ev, row: i64, col: i64, area_n: i64, two_args: bool) -> Ev {
|
||||
if row < 0 || col < 0 {
|
||||
@@ -203,7 +231,7 @@ impl<'h> Evaluator<'h> {
|
||||
let horizontal = l.range.height() == 1 && l.range.width() > 1;
|
||||
let same_len = if vertical { r.range.height() == l.range.height() } else { r.range.width() == l.range.width() };
|
||||
if !matches!(key, Value::Array(_)) && (vertical || horizontal) && same_len {
|
||||
let mut xargs = vec![Arg { value: key, from_ref: false }, Arg { value: self.area_value(&l), from_ref: true }];
|
||||
let mut xargs = vec![Arg::val(key), Arg::reference(self.area_value(&l))];
|
||||
// An empty match_mode before a given search_mode is the default (0), not the end.
|
||||
let given = |i: usize| args.get(i).is_some_and(|e| !matches!(e, Expr::Missing));
|
||||
let last = if given(5) {
|
||||
@@ -215,10 +243,11 @@ impl<'h> Evaluator<'h> {
|
||||
};
|
||||
for i in 4..=last {
|
||||
match args.get(i) {
|
||||
Some(Expr::Missing) | None => xargs.push(Arg { value: Value::Number(0.0), from_ref: false }),
|
||||
Some(Expr::Missing) | None => xargs.push(Arg::val(Value::Number(0.0))),
|
||||
Some(e) => {
|
||||
let value = self.value(e);
|
||||
xargs.push(Arg { value, from_ref: false });
|
||||
let blank_value = value == Value::Empty;
|
||||
xargs.push(Arg { value, from_ref: false, blank_value });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -407,3 +436,71 @@ impl<'h> Evaluator<'h> {
|
||||
Ev::V(gridcraft_functions::aggregate_values(func, &values))
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether INDEX on an array picks a whole row or column of several values at position
|
||||
/// (`row`, `col`), which the array-of-positions form answers with #CALC!.
|
||||
fn index_picks_several_values(src: &Ev, row: i64, col: i64, two_args: bool) -> bool {
|
||||
let Ev::V(Value::Array(arr)) = src else { return false };
|
||||
let (row, col) = if two_args && arr.rows == 1 { (1, row) } else { (row, col) };
|
||||
if row < 0 || col < 0 || row as u64 > arr.rows as u64 || col as u64 > arr.cols as u64 {
|
||||
return false;
|
||||
}
|
||||
(row == 0 && arr.rows > 1) || (col == 0 && arr.cols > 1)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::Host;
|
||||
use super::*;
|
||||
use gridcraft_model::Workbook;
|
||||
|
||||
/// A sheet of blanks that counts the blocks it is asked for.
|
||||
struct Counting {
|
||||
wb: Workbook,
|
||||
cells_read: usize,
|
||||
}
|
||||
impl Host for Counting {
|
||||
fn workbook(&self) -> &Workbook {
|
||||
&self.wb
|
||||
}
|
||||
fn cell_value(&mut self, _sheet: usize, _c: CellRef) -> Value {
|
||||
Value::Empty
|
||||
}
|
||||
fn now_serial(&self) -> f64 {
|
||||
0.0
|
||||
}
|
||||
fn random(&mut self) -> f64 {
|
||||
0.0
|
||||
}
|
||||
fn range_values(&mut self, _sheet: usize, range: RangeRef) -> Vec<Value> {
|
||||
self.cells_read += range.count() as usize;
|
||||
vec![Value::Empty; range.count() as usize]
|
||||
}
|
||||
}
|
||||
|
||||
fn eval(src: &str) -> (Value, usize) {
|
||||
let mut host = Counting { wb: Workbook::default(), cells_read: 0 };
|
||||
let mut ev = Evaluator::new(&mut host, 0, CellRef::new(0, 0));
|
||||
let v = ev.value(&gridcraft_formula::parser::parse(src).unwrap_or_else(|e| panic!("{e}")));
|
||||
(v, host.cells_read)
|
||||
}
|
||||
|
||||
fn all_calc(v: &Value, n: usize) -> bool {
|
||||
matches!(v, Value::Array(a) if a.data.len() == n && a.data.iter().all(|x| *x == Value::Error(CellError::Calc)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_positions_that_pick_several_cells_are_calc_without_reading_them() {
|
||||
// Each zero row position picks the whole of A1:A50000: #CALC!, decided from the shape. It
|
||||
// used to read the 50,000 cells once per position before discarding them.
|
||||
let (v, read) = eval("=INDEX(A1:A50000,{0,0,0,0},1)");
|
||||
assert!(all_calc(&v, 4), "{v:?}");
|
||||
assert_eq!(read, 0);
|
||||
// The same for an array source: a whole column of a 3x3 array.
|
||||
let (v, _) = eval("=INDEX({1,2,3;4,5,6;7,8,9},{0,0},2)");
|
||||
assert!(all_calc(&v, 2), "{v:?}");
|
||||
// Single cells are still read.
|
||||
let (v, _) = eval("=INDEX({1,2,3;4,5,6;7,8,9},{1,3},2)");
|
||||
assert!(matches!(&v, Value::Array(a) if a.data == vec![Value::number(2.0), Value::number(8.0)]), "{v:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
pub mod eval;
|
||||
mod precision;
|
||||
pub mod recalc;
|
||||
mod spill_index;
|
||||
mod trimrange;
|
||||
|
||||
pub use eval::{Area, SPECIAL_FUNCTIONS, is_known_function};
|
||||
|
||||
+181
-33
@@ -10,13 +10,24 @@
|
||||
//! and the formulas that read one are evaluated again until the loop settles
|
||||
//! (`PassHost::iterate_loops`).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
||||
|
||||
use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value};
|
||||
use gridcraft_formula::Expr;
|
||||
use gridcraft_model::{CalcMode, Workbook};
|
||||
|
||||
use crate::eval::{Evaluator, Host, precedents, stack_position};
|
||||
use crate::spill_index::{SpillIndex, indexing_pays};
|
||||
|
||||
/// Changed cells (in one `cells_changed`) from which a sheet's spill areas may be indexed.
|
||||
const INDEX_SPILLS_FROM: usize = 16;
|
||||
|
||||
/// Whether a sheet's spill areas are indexed for a batch of `batch` changed cells of which
|
||||
/// `on_sheet` are on that sheet: the batch must be a paste, and registering the areas must cost
|
||||
/// less than walking them once for each of the sheet's own changed cells.
|
||||
fn index_spills<'a>(batch: usize, on_sheet: usize, areas: impl ExactSizeIterator<Item = &'a RangeRef>) -> bool {
|
||||
batch > INDEX_SPILLS_FROM && areas.len() > 0 && indexing_pays(areas, on_sheet)
|
||||
}
|
||||
|
||||
mod graph;
|
||||
mod pass_host;
|
||||
@@ -37,11 +48,61 @@ pub struct Calc {
|
||||
/// Cells whose formulas have errors from cycles.
|
||||
pub circular: Vec<Key>,
|
||||
pub last_recalc_cells: usize,
|
||||
/// The cells whose value is `#SPILL!`, kept so that finding the formulas an emptied cell or a
|
||||
/// freed spill area may unblock doesn't walk every formula or window of cells. Filled in
|
||||
/// `rebuild`, kept up to date by `cells_changed` and by the write-back of a run; an entry is
|
||||
/// always checked against the workbook before it is used.
|
||||
spill_errors: SpillErrors,
|
||||
}
|
||||
|
||||
/// A set of cells by row, each row's columns in their own set: a window of rows and columns is
|
||||
/// found by one column range per row that has an entry, so the cost follows the cells in the
|
||||
/// window, not those to the side of it in the same rows.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct SpillErrors {
|
||||
rows: BTreeMap<(usize, u32), BTreeSet<u32>>,
|
||||
}
|
||||
|
||||
impl SpillErrors {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.rows.is_empty()
|
||||
}
|
||||
|
||||
fn clear(&mut self) {
|
||||
self.rows.clear();
|
||||
}
|
||||
|
||||
fn insert(&mut self, (sheet, c): Key) {
|
||||
self.rows.entry((sheet, c.row)).or_default().insert(c.col);
|
||||
}
|
||||
|
||||
fn remove(&mut self, (sheet, c): &Key) {
|
||||
if let Some(cols) = self.rows.get_mut(&(*sheet, c.row)) {
|
||||
cols.remove(&c.col);
|
||||
if cols.is_empty() {
|
||||
self.rows.remove(&(*sheet, c.row));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The cells of `sheet` in `rows` x `cols` (both inclusive), in row-major order.
|
||||
fn range(&self, sheet: usize, rows: (u32, u32), cols: (u32, u32)) -> impl Iterator<Item = CellRef> + '_ {
|
||||
self.rows
|
||||
.range((sheet, rows.0)..=(sheet, rows.1))
|
||||
.flat_map(move |(&(_, row), set)| set.range(cols.0..=cols.1).map(move |&col| CellRef::new(row, col)))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Calc {
|
||||
fn default() -> Self {
|
||||
Calc { graph: Graph::default(), rng: 0x9E37_79B9_7F4A_7C15, fixed_now: None, circular: vec![], last_recalc_cells: 0 }
|
||||
Calc {
|
||||
graph: Graph::default(),
|
||||
rng: 0x9E37_79B9_7F4A_7C15,
|
||||
fixed_now: None,
|
||||
circular: vec![],
|
||||
last_recalc_cells: 0,
|
||||
spill_errors: SpillErrors::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,12 +186,15 @@ impl Calc {
|
||||
pub fn rebuild(&mut self, wb: &Workbook) {
|
||||
self.graph = Graph::default();
|
||||
let mut circ = Vec::new();
|
||||
self.spill_errors.clear();
|
||||
for (si, sheet) in wb.sheets.iter().enumerate() {
|
||||
for (c, cell) in sheet.cells.iter() {
|
||||
if cell.value == Value::Error(CellError::Spill) {
|
||||
self.spill_errors.insert((si, c));
|
||||
}
|
||||
if let Some(f) = &cell.formula
|
||||
&& let Some(e) = f.expr()
|
||||
&& let Some((areas, dynamic)) = f.with_expr(|e| precedents(wb, si, e))
|
||||
{
|
||||
let (areas, dynamic) = precedents(wb, si, &e);
|
||||
self.graph.insert((si, c), Node { areas, dynamic });
|
||||
if cell.value == Value::Error(CellError::Circ) {
|
||||
circ.push((si, c));
|
||||
@@ -188,6 +252,20 @@ impl Calc {
|
||||
out
|
||||
}
|
||||
|
||||
/// The cells of `sheet` in `rows` x `cols` (inclusive, `cols` a lower and an upper column) that
|
||||
/// show `#SPILL!`, in row-major order.
|
||||
fn spill_errors_in(&self, wb: &Workbook, sheet: usize, rows: (u32, u32), cols: (u32, u32)) -> Vec<CellRef> {
|
||||
let Some(sh) = wb.sheet(sheet) else { return Vec::new() };
|
||||
self.spill_errors.range(sheet, rows, cols).filter(|c| sh.cells.get(*c).is_some_and(|x| x.value == Value::Error(CellError::Spill))).collect()
|
||||
}
|
||||
|
||||
/// The formulas showing `#SPILL!` on `sheet` at or above and to the left of `end`: what a
|
||||
/// cell emptied or a spill area freed there may unblock.
|
||||
fn unblock_candidates(&self, wb: &Workbook, sheet: usize, end: CellRef) -> Vec<Key> {
|
||||
let cells = self.spill_errors_in(wb, sheet, (0, end.row), (0, end.col));
|
||||
cells.into_iter().filter(|c| self.graph.nodes.contains_key(&(sheet, *c))).map(|c| (sheet, c)).collect()
|
||||
}
|
||||
|
||||
/// Recalculates every formula (F9 / Ctrl+Alt+F9, and after loading).
|
||||
pub fn recalc_all(&mut self, wb: &mut Workbook) {
|
||||
self.rebuild(wb);
|
||||
@@ -209,14 +287,19 @@ impl Calc {
|
||||
pub fn cells_changed(&mut self, wb: &mut Workbook, changed: &[Key]) {
|
||||
let mut cleared: Vec<(usize, RangeRef)> = Vec::new();
|
||||
for &k in changed {
|
||||
let formula = wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.clone());
|
||||
if wb.sheet(k.0).and_then(|s| s.cell(k.1)).is_some_and(|c| c.value == Value::Error(CellError::Spill)) {
|
||||
self.spill_errors.insert(k);
|
||||
} else {
|
||||
self.spill_errors.remove(&k);
|
||||
}
|
||||
let precedent =
|
||||
wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.formula.as_ref()).and_then(|f| f.with_expr(|e| precedents(wb, k.0, e)));
|
||||
let py = wb.sheet(k.0).and_then(|s| s.cell(k.1)).and_then(|c| c.py.as_ref());
|
||||
let python = py.is_some();
|
||||
// A Python cell whose result (if any) is not an array spills nothing.
|
||||
let spills = py.and_then(|p| p.result_value()).is_some_and(|v| matches!(v, Value::Array(_)));
|
||||
match formula.and_then(|f| f.expr()) {
|
||||
Some(e) => {
|
||||
let (areas, dynamic) = precedents(wb, k.0, &e);
|
||||
match precedent {
|
||||
Some((areas, dynamic)) => {
|
||||
self.graph.insert(k, Node { areas, dynamic });
|
||||
}
|
||||
// See `rebuild`: its spill is laid out again from its result when it is calculated.
|
||||
@@ -249,6 +332,7 @@ impl Calc {
|
||||
&& cell.formula.is_some()
|
||||
{
|
||||
cell.value = Value::Error(CellError::Name);
|
||||
self.spill_errors.remove(&k);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -264,17 +348,40 @@ impl Calc {
|
||||
let mut emptied: Vec<Key> = Vec::new();
|
||||
// Typing into (or clearing) a cell a dynamic array spills over (or would spill over)
|
||||
// re-evaluates the anchor, which may now be blocked or unblocked.
|
||||
// With many changed cells (a paste) a sheet's spill areas are indexed once instead of being
|
||||
// walked for each one, when registering them costs less than walking them would.
|
||||
let mut areas = SpillIndex::default();
|
||||
let mut strategy: HashMap<usize, bool> = HashMap::default();
|
||||
let mut per_sheet: HashMap<usize, usize> = HashMap::default();
|
||||
for k in changed {
|
||||
*per_sheet.entry(k.0).or_insert(0) += 1;
|
||||
}
|
||||
let mut at = Vec::new();
|
||||
for &k in changed {
|
||||
if let Some(sh) = wb.sheet(k.0) {
|
||||
for (anchor, r) in &sh.spill_ranges {
|
||||
if *anchor != k.1 && r.contains(k.1) {
|
||||
seeds.push((k.0, *anchor));
|
||||
let indexed = *strategy.entry(k.0).or_insert_with(|| {
|
||||
let indexed = index_spills(changed.len(), per_sheet[&k.0], sh.spill_ranges.values());
|
||||
if indexed {
|
||||
for (anchor, r) in &sh.spill_ranges {
|
||||
areas.insert_rect((k.0, *anchor), *r, false);
|
||||
}
|
||||
}
|
||||
indexed
|
||||
});
|
||||
if indexed {
|
||||
// Anchor order, as walking `spill_ranges` gives.
|
||||
areas.meeting(k.0, RangeRef::new(k.1, k.1), &mut at);
|
||||
seeds.extend(at.iter().filter(|a| **a != k.1).map(|a| (k.0, *a)));
|
||||
} else {
|
||||
for (anchor, r) in &sh.spill_ranges {
|
||||
if *anchor != k.1 && r.contains(k.1) {
|
||||
seeds.push((k.0, *anchor));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (c, cell) in sh.cells.iter_range(RangeRef::new(CellRef::new(k.1.row.saturating_sub(64), k.1.col.saturating_sub(64)), k.1)) {
|
||||
if c != k.1 && cell.value == Value::Error(CellError::Spill) {
|
||||
seeds.push((k.0, c));
|
||||
}
|
||||
if !self.spill_errors.is_empty() {
|
||||
let (r0, c0) = (k.1.row.saturating_sub(64), k.1.col.saturating_sub(64));
|
||||
seeds.extend(self.spill_errors_in(wb, k.0, (r0, k.1.row), (c0, k.1.col)).into_iter().filter(|c| *c != k.1).map(|c| (k.0, c)));
|
||||
}
|
||||
if !sh.cells.has(k.1) {
|
||||
match emptied.iter_mut().find(|x| x.0 == k.0) {
|
||||
@@ -286,11 +393,7 @@ impl Calc {
|
||||
}
|
||||
// An emptied cell can unblock an anchor any distance above or to the left of it.
|
||||
for (si, end) in emptied {
|
||||
for &(s, c) in self.graph.nodes.keys() {
|
||||
if s == si && c.row <= end.row && c.col <= end.col && wb.sheet(s).is_some_and(|sh| sh.value(c) == Value::Error(CellError::Spill)) {
|
||||
seeds.push((s, c));
|
||||
}
|
||||
}
|
||||
seeds.extend(self.unblock_candidates(wb, si, end));
|
||||
}
|
||||
// Spill areas of changed anchors also change. They stay rectangles: one seed per spilled
|
||||
// cell listed the formulas reading the area once per cell.
|
||||
@@ -454,10 +557,15 @@ impl Calc {
|
||||
let mut freed = Vec::new();
|
||||
for (key, value) in results {
|
||||
let Some(sheet) = wb.sheet_mut(key.0) else { continue };
|
||||
if let Some(cell) = sheet.cells.get_mut(key.1)
|
||||
&& cell.value != value
|
||||
{
|
||||
cell.value = value;
|
||||
if let Some(cell) = sheet.cells.get_mut(key.1) {
|
||||
if value == Value::Error(CellError::Spill) {
|
||||
self.spill_errors.insert(key);
|
||||
} else {
|
||||
self.spill_errors.remove(&key);
|
||||
}
|
||||
if cell.value != value {
|
||||
cell.value = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
for key in dirty {
|
||||
@@ -487,6 +595,7 @@ impl Calc {
|
||||
if blocked {
|
||||
if let Some(cell) = sheet.cells.get_mut(key.1) {
|
||||
cell.value = Value::Error(CellError::Spill);
|
||||
self.spill_errors.insert(*key);
|
||||
}
|
||||
let mut blocked_dependents = Vec::new();
|
||||
self.graph.dependents_of_range(key.0, range, &mut blocked_dependents);
|
||||
@@ -513,14 +622,7 @@ impl Calc {
|
||||
}
|
||||
}
|
||||
for (sheet_index, range) in freed {
|
||||
for &(sheet, cell) in self.graph.nodes.keys() {
|
||||
let was_blocked_cell = cell.row <= range.end.row
|
||||
&& cell.col <= range.end.col
|
||||
&& wb.sheet(sheet).is_some_and(|sheet| sheet.value(cell) == Value::Error(CellError::Spill));
|
||||
if sheet == sheet_index && was_blocked_cell {
|
||||
spill_changes.push((sheet, cell));
|
||||
}
|
||||
}
|
||||
spill_changes.extend(self.unblock_candidates(wb, sheet_index, range.end));
|
||||
}
|
||||
spill_changes
|
||||
}
|
||||
@@ -577,7 +679,7 @@ pub(crate) mod tests {
|
||||
use gridcraft_core::{CellRef, RangeRef, Value};
|
||||
use gridcraft_model::{Cell, Formula, Workbook};
|
||||
|
||||
use super::{Calc, HashMap, Host, Intervals, Key, PassHost};
|
||||
use super::{Calc, HashMap, Host, Intervals, Key, PassHost, index_spills};
|
||||
|
||||
/// xorshift64 for repeatable pseudo-random cases.
|
||||
fn rng(state: &mut u64) -> u64 {
|
||||
@@ -594,6 +696,19 @@ pub(crate) mod tests {
|
||||
v
|
||||
}
|
||||
|
||||
/// One changed cell on a sheet with many small spills does not index them just because the
|
||||
/// batch (mostly on another sheet) is large; the sheet's own share of the batch decides.
|
||||
#[test]
|
||||
fn the_spill_index_is_chosen_from_the_sheets_own_changed_cells() {
|
||||
let small: Vec<RangeRef> = (0..1_000).map(|i| RangeRef::new(CellRef::new(i * 3, 0), CellRef::new(i * 3 + 2, 0))).collect();
|
||||
// Same areas, same batch of 5000: indexed when 5000 of them are on the sheet, walked when 1 is.
|
||||
assert!(index_spills(5_000, 5_000, small.iter()));
|
||||
assert!(!index_spills(5_000, 1, small.iter()));
|
||||
// Not a paste, or nothing to index: never.
|
||||
assert!(!index_spills(16, 16, small.iter()));
|
||||
assert!(!index_spills(5_000, 5_000, [].iter()));
|
||||
}
|
||||
|
||||
/// A 1,000 x 1,000 Python result laid out from A1 in a pass, with the same values already
|
||||
/// spilled on the sheet from the last calculation, as a full recalculation finds them.
|
||||
fn big_python_spill() -> (Workbook, HashMap<Key, Value>) {
|
||||
@@ -636,6 +751,39 @@ pub(crate) mod tests {
|
||||
assert!(!host.visit_range(0, inside, &mut |_| true), "an overlapping read leaves the block to range_values");
|
||||
}
|
||||
|
||||
/// A Python array laid out in this pass is seen by the indexed readers, whether the sheet
|
||||
/// has no spill from before or an older, smaller one.
|
||||
#[test]
|
||||
fn a_python_spill_found_in_the_pass_is_read_through_the_index() {
|
||||
for stale in [false, true] {
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
let anchor = CellRef::new(0, 0);
|
||||
let py = gridcraft_model::PyCode::new("grid");
|
||||
sh.set_cell(anchor, Cell { value: Value::Number(0.0), py: Some(std::sync::Arc::new(py)), ..Cell::default() });
|
||||
if stale {
|
||||
// The last calculation spilled a 2 x 2 block of 100s.
|
||||
sh.spill_ranges.insert(anchor, RangeRef::new(anchor, CellRef::new(1, 1)));
|
||||
for c in RangeRef::new(anchor, CellRef::new(1, 1)).iter().skip(1) {
|
||||
sh.spill.insert(c, Value::Number(100.0));
|
||||
}
|
||||
}
|
||||
let data: Vec<Value> = (1..=9).map(|i| Value::Number(f64::from(i))).collect();
|
||||
let array = gridcraft_core::Array::new(3, 3, data).unwrap();
|
||||
let mut python = HashMap::default();
|
||||
python.insert((0, anchor), Value::Array(std::sync::Arc::new(array)));
|
||||
let exprs = HashMap::default();
|
||||
let mut rng = 1u64;
|
||||
let mut host = PassHost::new(&wb, &exprs, &mut rng, 0.0).with_python(python);
|
||||
assert_eq!(host.cell_value(0, anchor), Value::Number(1.0));
|
||||
let all = RangeRef::parse("A1:C3").unwrap();
|
||||
let sum: f64 = host.range_values(0, all).iter().map(|v| if let Value::Number(n) = v { *n } else { 0.0 }).sum();
|
||||
assert_eq!(sum, 45.0, "stale={stale}: a SUM over the new 3 x 3 spill reads all of it");
|
||||
assert_eq!(host.cell_value(0, CellRef::new(2, 2)), Value::Number(9.0), "stale={stale}");
|
||||
assert!(!host.visit_range(0, all, &mut |_| true), "stale={stale}: the new spill leaves the block to range_values");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intervals_match_a_linear_scan() {
|
||||
let mut iv: Intervals<u32> = Intervals::default();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::graph::{HashMap, HashSet, Key};
|
||||
use super::{Evaluator, Host, Workbook, stack_position};
|
||||
use crate::spill_index::{Cover, SpillIndex};
|
||||
use gridcraft_core::{Array, CellError, CellRef, RangeRef, Value};
|
||||
use gridcraft_formula::Expr;
|
||||
|
||||
@@ -13,6 +14,9 @@ const MAX_NESTED: usize = 64;
|
||||
/// evaluated again), however high Maximum Iterations is.
|
||||
const MAX_ITERATION_WORK: usize = 10_000_000;
|
||||
|
||||
/// A block's results by last row: its sum and count, or `None` when it can't be summed from constants.
|
||||
type BlockSums = std::collections::BTreeMap<u32, Option<(f64, u64)>>;
|
||||
|
||||
pub(super) struct PassHost<'a> {
|
||||
wb: &'a Workbook,
|
||||
exprs: &'a HashMap<Key, std::sync::Arc<Expr>>,
|
||||
@@ -21,6 +25,8 @@ pub(super) struct PassHost<'a> {
|
||||
in_progress: HashSet<Key>,
|
||||
/// New spills found during the pass: anchor → array.
|
||||
pub(super) spills: HashMap<Key, std::sync::Arc<Array>>,
|
||||
/// Where the areas in `spills` are, so a read finds the ones it meets without scanning them all.
|
||||
spill_index: SpillIndex,
|
||||
/// Used range per sheet: the workbook doesn't change during a pass, and working it out walks
|
||||
/// every row, so once per sheet rather than once per range read.
|
||||
used: HashMap<usize, Option<RangeRef>>,
|
||||
@@ -71,6 +77,10 @@ pub(super) struct PassHost<'a> {
|
||||
stack_top: usize,
|
||||
/// Whether an evaluation ran past the stack budget since the innermost formula started.
|
||||
out_of_stack: bool,
|
||||
/// Sum and count of blocks that hold only constants, for `range_sum`. Keyed by sheet, first
|
||||
/// cell and last column; each value maps a last row to the block's result (`None`: the block
|
||||
/// has a formula, a Python cell, an error value or a spilled value, so every longer one has too).
|
||||
sums: HashMap<(usize, CellRef, u32), BlockSums>,
|
||||
/// The kept results of the Python cells in the pass: a Python cell isn't evaluated, its
|
||||
/// result is laid out (an array spills from the cell, as a formula's does). Each one is in
|
||||
/// `pending` until it is laid out, so a formula that reads it first gets its new value.
|
||||
@@ -86,6 +96,7 @@ impl<'a> PassHost<'a> {
|
||||
pending: exprs.keys().copied().collect(),
|
||||
in_progress: HashSet::default(),
|
||||
spills: HashMap::default(),
|
||||
spill_index: SpillIndex::default(),
|
||||
used: HashMap::default(),
|
||||
rng,
|
||||
now,
|
||||
@@ -110,6 +121,7 @@ impl<'a> PassHost<'a> {
|
||||
precision: wb.calc.precision_as_displayed.then(crate::DisplayedPrecision::new),
|
||||
stack_top: 0,
|
||||
out_of_stack: false,
|
||||
sums: HashMap::default(),
|
||||
python: HashMap::default(),
|
||||
}
|
||||
}
|
||||
@@ -123,6 +135,30 @@ impl<'a> PassHost<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Most spill areas a range read meets before they are checked for overlapping one another by
|
||||
/// walking all of the pass's spills instead of comparing pairs.
|
||||
const MAX_UNORDERED_HITS: usize = 16;
|
||||
|
||||
/// Whether an array at `anchor` covers `c`.
|
||||
fn spill_covers(anchor: CellRef, arr: &Array, c: CellRef) -> bool {
|
||||
c.row >= anchor.row && c.col >= anchor.col && ((c.row - anchor.row) as usize) < arr.rows && ((c.col - anchor.col) as usize) < arr.cols
|
||||
}
|
||||
|
||||
/// Whether two of the spill areas anchored at `hits` share a cell inside `range`.
|
||||
fn spill_hits_overlap(index: &SpillIndex, sheet: usize, hits: &[CellRef], range: RangeRef) -> bool {
|
||||
let rects: Vec<RangeRef> = hits.iter().filter_map(|a| index.area(sheet, *a)).filter(|(_, empty)| !empty).map(|(r, _)| r).collect();
|
||||
rects.iter().enumerate().any(|(i, a)| {
|
||||
rects.iter().skip(i + 1).any(|b| {
|
||||
a.intersects(b)
|
||||
&& RangeRef::new(
|
||||
CellRef::new(a.start.row.max(b.start.row), a.start.col.max(b.start.col)),
|
||||
CellRef::new(a.end.row.min(b.end.row), a.end.col.min(b.end.col)),
|
||||
)
|
||||
.intersects(&range)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether an array result at `k` can't spill: it is in a table, or its area runs off the sheet,
|
||||
/// overlaps merged cells, holds other cells, or another formula's spill (unless that formula is
|
||||
/// recalculated in this pass, and so may spill elsewhere now).
|
||||
@@ -161,13 +197,6 @@ pub(super) fn spills_as_before(sheet: &gridcraft_model::Sheet, anchor: CellRef,
|
||||
.all(|(i, c)| c == anchor || (!sheet.cells.has(c) && sheet.spill.get(&c) == Some(arr.data.get(i).unwrap_or(&Value::Empty))))
|
||||
}
|
||||
|
||||
/// The area an array anchored at `anchor` covers.
|
||||
fn array_area(anchor: CellRef, a: &Array) -> RangeRef {
|
||||
let end =
|
||||
CellRef::new(anchor.row.saturating_add((a.rows as u32).saturating_sub(1)), anchor.col.saturating_add((a.cols as u32).saturating_sub(1)));
|
||||
RangeRef::new(anchor, end)
|
||||
}
|
||||
|
||||
/// The spilled values `sheet` stores inside `range`, passed to `f` until it returns false. Values
|
||||
/// outside the range are not walked: with fewer rows in the range than spilled values, each row
|
||||
/// is looked up on its own; otherwise one ordered scan visits each spilled value at most once.
|
||||
@@ -369,6 +398,7 @@ impl PassHost<'_> {
|
||||
// value -- a FILTER matching one row, a SORT or UNIQUE over one item -- so `C1#`
|
||||
// is the 1 x 1 range and `ROWS(C1#)` is 1, as in Excel (#1491). A formula that
|
||||
// returns a scalar never reaches this arm, so it does not become an anchor.
|
||||
self.spill_index.insert(k, a.rows, a.cols);
|
||||
self.spills.insert(k, a.clone());
|
||||
a.data.first().cloned().unwrap_or_default()
|
||||
}
|
||||
@@ -383,6 +413,7 @@ impl PassHost<'_> {
|
||||
let v = match self.spills.get(&k) {
|
||||
Some(a) if spill_blocked(self, k, a) => {
|
||||
self.spills.remove(&k);
|
||||
self.spill_index.remove(k);
|
||||
Value::Error(CellError::Spill)
|
||||
}
|
||||
_ => v,
|
||||
@@ -402,6 +433,7 @@ impl PassHost<'_> {
|
||||
&& a.cols >= 1
|
||||
&& a.rows.checked_mul(a.cols).is_some_and(|n| n == a.data.len() && n <= gridcraft_model::python::MAX_RESULT_CELLS);
|
||||
if sane {
|
||||
self.spill_index.insert(k, a.rows, a.cols);
|
||||
self.spills.insert(k, a.clone());
|
||||
a.data.first().cloned().unwrap_or_default()
|
||||
} else {
|
||||
@@ -413,6 +445,7 @@ impl PassHost<'_> {
|
||||
let v = match self.spills.get(&k) {
|
||||
Some(a) if spill_blocked(self, k, a) => {
|
||||
self.spills.remove(&k);
|
||||
self.spill_index.remove(k);
|
||||
Value::Error(CellError::Spill)
|
||||
}
|
||||
_ => v,
|
||||
@@ -459,7 +492,11 @@ impl PassHost<'_> {
|
||||
while passes < max_iterations.min(max_passes) && !self.converged(max_change) {
|
||||
self.prev = again.iter().filter_map(|k| self.results.remove(k).map(|v| (*k, v))).collect();
|
||||
self.pending = set.clone();
|
||||
self.spills.retain(|a, _| !set.contains(a));
|
||||
let gone: Vec<Key> = self.spills.keys().filter(|a| set.contains(a)).copied().collect();
|
||||
for a in gone {
|
||||
self.spills.remove(&a);
|
||||
self.spill_index.remove(a);
|
||||
}
|
||||
for k in &again {
|
||||
self.calculate(*k);
|
||||
}
|
||||
@@ -543,17 +580,19 @@ impl Host for PassHost<'_> {
|
||||
if self.pending.contains(&k) || self.results.contains_key(&k) {
|
||||
return self.compute(k);
|
||||
}
|
||||
// A cell covered by a spill computed in this pass.
|
||||
for (anchor, arr) in &self.spills {
|
||||
if anchor.0 == sheet && c.row >= anchor.1.row && c.col >= anchor.1.col {
|
||||
let (dr, dc) = ((c.row - anchor.1.row) as usize, (c.col - anchor.1.col) as usize);
|
||||
if dr < arr.rows && dc < arr.cols {
|
||||
if self.iterate && self.tainted.contains(anchor) {
|
||||
self.taint = true;
|
||||
}
|
||||
return arr.get(dr, dc).cloned().unwrap_or_default();
|
||||
}
|
||||
// A cell covered by a spill computed in this pass. Where several overlap, the first in
|
||||
// `spills`' iteration order counts, as it always has.
|
||||
let covering = match self.spill_index.covering(sheet, c) {
|
||||
Cover::None => None,
|
||||
Cover::One(a) => self.spills.get_key_value(&(sheet, a)),
|
||||
Cover::Many => self.spills.iter().find(|(a, arr)| a.0 == sheet && spill_covers(a.1, arr, c)),
|
||||
};
|
||||
if let Some((anchor, arr)) = covering {
|
||||
let (dr, dc) = ((c.row - anchor.1.row) as usize, (c.col - anchor.1.col) as usize);
|
||||
if self.iterate && self.tainted.contains(anchor) {
|
||||
self.taint = true;
|
||||
}
|
||||
return arr.get(dr, dc).cloned().unwrap_or_default();
|
||||
}
|
||||
self.wb.sheet(sheet).map(|s| s.value(c)).unwrap_or_default()
|
||||
}
|
||||
@@ -592,46 +631,44 @@ impl Host for PassHost<'_> {
|
||||
}
|
||||
}
|
||||
if !self.spills.is_empty() {
|
||||
// Only the part of each array inside the range is walked: a large array beside a small
|
||||
// read costs nothing.
|
||||
let mut new: Vec<(usize, Value)> = Vec::new();
|
||||
for (a, arr) in self.spills.iter().filter(|(a, _)| a.0 == sheet) {
|
||||
let area = array_area(a.1, arr);
|
||||
if !area.intersects(&range) {
|
||||
continue;
|
||||
}
|
||||
let (r0, r1) = (area.start.row.max(range.start.row), area.end.row.min(range.end.row));
|
||||
let (c0, c1) = (area.start.col.max(range.start.col), area.end.col.min(range.end.col));
|
||||
for row in r0..=r1 {
|
||||
for col in c0..=c1 {
|
||||
let c = CellRef::new(row, col);
|
||||
if c == a.1 {
|
||||
continue;
|
||||
}
|
||||
let v = arr.get((row - a.1.row) as usize, (col - a.1.col) as usize).cloned().unwrap_or_default();
|
||||
new.push(((row - range.start.row) as usize * w + (col - range.start.col) as usize, v));
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.spill_visits += new.len();
|
||||
}
|
||||
let mut hits = Vec::new();
|
||||
self.spill_index.meeting(sheet, range, &mut hits);
|
||||
// Iterating: an array spilled by a formula that read a previous value.
|
||||
if self.iterate
|
||||
&& self.spills.iter().any(|(a, arr)| {
|
||||
let end = CellRef::new(
|
||||
a.1.row.saturating_add((arr.rows as u32).saturating_sub(1)),
|
||||
a.1.col.saturating_add((arr.cols as u32).saturating_sub(1)),
|
||||
);
|
||||
a.0 == sheet && self.tainted.contains(a) && RangeRef::new(a.1, end).intersects(&range)
|
||||
})
|
||||
{
|
||||
if self.iterate && hits.iter().any(|a| self.tainted.contains(&(sheet, *a))) {
|
||||
self.taint = true;
|
||||
}
|
||||
for (i, v) in new {
|
||||
if let Some(slot) = out.get_mut(i) {
|
||||
*slot = v;
|
||||
// Where areas overlap inside the range, the last in `spills`' iteration order wins,
|
||||
// as it always has; otherwise the order doesn't matter.
|
||||
let ordered = hits.len() > MAX_UNORDERED_HITS || spill_hits_overlap(&self.spill_index, sheet, &hits, range);
|
||||
let mut put = |anchor: CellRef, arr: &Array| {
|
||||
if arr.rows == 0 || arr.cols == 0 {
|
||||
return;
|
||||
}
|
||||
let (r0, c0) = (range.start.row.max(anchor.row), range.start.col.max(anchor.col));
|
||||
let r1 = range.end.row.min(anchor.row.saturating_add((arr.rows as u32).saturating_sub(1)));
|
||||
let c1 = range.end.col.min(anchor.col.saturating_add((arr.cols as u32).saturating_sub(1)));
|
||||
for r in r0..=r1 {
|
||||
for cc in c0..=c1 {
|
||||
if (r, cc) != (anchor.row, anchor.col)
|
||||
&& let Some(slot) = out.get_mut((r - range.start.row) as usize * w + (cc - range.start.col) as usize)
|
||||
{
|
||||
*slot = arr.get((r - anchor.row) as usize, (cc - anchor.col) as usize).cloned().unwrap_or_default();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
if ordered {
|
||||
let wanted: HashSet<Key> = hits.iter().map(|a| (sheet, *a)).collect();
|
||||
for (a, arr) in &self.spills {
|
||||
if wanted.contains(a) {
|
||||
put(a.1, arr);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for a in &hits {
|
||||
if let Some(arr) = self.spills.get(&(sheet, *a)) {
|
||||
put(*a, arr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -650,7 +687,8 @@ impl Host for PassHost<'_> {
|
||||
}
|
||||
// Spilled values sit between the stored cells (and the formulas just calculated may have
|
||||
// produced some): leave such blocks to `range_values`.
|
||||
let arrays = self.spills.iter().any(|(a, arr)| a.0 == sheet && array_area(a.1, arr).intersects(&range));
|
||||
let mut arrays = Vec::new();
|
||||
self.spill_index.meeting(sheet, range, &mut arrays);
|
||||
let mut stored = false;
|
||||
let _seen = stored_spill_in(sh, range, &mut |_, _| {
|
||||
stored = true;
|
||||
@@ -660,7 +698,7 @@ impl Host for PassHost<'_> {
|
||||
{
|
||||
self.spill_visits += _seen;
|
||||
}
|
||||
if arrays || stored {
|
||||
if !arrays.is_empty() || stored {
|
||||
return false;
|
||||
}
|
||||
for (c, cell) in sh.cells.iter_range(range) {
|
||||
@@ -671,6 +709,50 @@ impl Host for PassHost<'_> {
|
||||
}
|
||||
true
|
||||
}
|
||||
fn range_sum(&mut self, sheet: usize, range: RangeRef) -> Option<(f64, u64)> {
|
||||
// Constants can't change during a pass, so a block's result stays valid for it. Spills
|
||||
// found in this pass (and iteration, which tracks what a read depends on) go the long way.
|
||||
if self.iterate || self.spills.keys().any(|a| a.0 == sheet) {
|
||||
return None;
|
||||
}
|
||||
let sh = self.wb.sheet(sheet)?;
|
||||
let (key, last) = ((sheet, range.start, range.end.col), range.end.row);
|
||||
let known = self.sums.entry(key).or_default();
|
||||
if let Some(r) = known.get(&last) {
|
||||
return *r;
|
||||
}
|
||||
// Start from the longest shorter block of the same rows from the top: summing row-major
|
||||
// from there on is the same additions in the same order as one walk from the start.
|
||||
let (from, mut sum, mut count) = match known.range(..last).next_back() {
|
||||
Some((_, None)) => {
|
||||
known.insert(last, None);
|
||||
return None;
|
||||
}
|
||||
Some((&row, &Some((sum, count)))) => (row + 1, sum, count),
|
||||
None => (range.start.row, 0.0, 0),
|
||||
};
|
||||
let rest = RangeRef::new(CellRef::new(from, range.start.col), range.end);
|
||||
let mut plain = sh.spill.range(rest.start..=rest.end).all(|(c, _)| !rest.contains(*c));
|
||||
if plain {
|
||||
for (_, cell) in sh.cells.iter_range(rest) {
|
||||
match &cell.value {
|
||||
_ if cell.formula.is_some() || cell.py.is_some() => plain = false,
|
||||
Value::Error(_) => plain = false,
|
||||
Value::Number(n) => {
|
||||
sum += n;
|
||||
count = u64::saturating_add(count, 1);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if !plain {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let result = plain.then_some((sum, count));
|
||||
known.insert(last, result);
|
||||
result
|
||||
}
|
||||
fn used_range(&mut self, sheet: usize) -> Option<RangeRef> {
|
||||
let wb = self.wb;
|
||||
*self.used.entry(sheet).or_insert_with(|| wb.sheet(sheet).and_then(|s| s.used_range()))
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
//! A spatial index over spill areas: which areas cover a cell, and which meet a range, without
|
||||
//! looking at every area. Areas are registered in 16 x 16 cell blocks, so a lookup touches one
|
||||
//! block (a cell) or the blocks a range covers (a range) rather than every spill on the sheet.
|
||||
//! An area that would take more than `BIG_AREA_BLOCKS` blocks (a full-height column spill takes
|
||||
//! 65,536) is not registered in blocks but kept in a short list that every lookup tests directly,
|
||||
//! so registering costs what the area's size warrants only for small areas.
|
||||
|
||||
use crate::recalc::{FxHasher, Key};
|
||||
use gridcraft_core::{CellRef, RangeRef};
|
||||
use std::hash::BuildHasherDefault;
|
||||
|
||||
type HashMap<K, V> = std::collections::HashMap<K, V, BuildHasherDefault<FxHasher>>;
|
||||
|
||||
/// Block edge in cells (rows and columns).
|
||||
const BLOCK: u32 = 16;
|
||||
|
||||
/// Areas spanning more blocks than this are tested directly instead of being registered.
|
||||
const BIG_AREA_BLOCKS: u64 = 1024;
|
||||
|
||||
/// One indexed area: its rectangle, and whether it covers no cells (an array with no rows or no
|
||||
/// columns still meets the ranges its anchor is in, as a 1 x 1 area does for taint tracking).
|
||||
#[derive(Clone, Copy)]
|
||||
struct Entry {
|
||||
rect: RangeRef,
|
||||
empty: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SheetAreas {
|
||||
entries: HashMap<CellRef, Entry>,
|
||||
blocks: HashMap<(u32, u32), Vec<CellRef>>,
|
||||
/// Anchors of the areas too big to register in blocks.
|
||||
big: Vec<CellRef>,
|
||||
}
|
||||
|
||||
/// What covers a cell.
|
||||
pub(crate) enum Cover {
|
||||
None,
|
||||
One(CellRef),
|
||||
/// Several areas overlap here: the caller decides which one counts.
|
||||
Many,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct SpillIndex {
|
||||
sheets: HashMap<usize, SheetAreas>,
|
||||
}
|
||||
|
||||
fn block_span(rect: &RangeRef) -> (u32, u32, u32, u32) {
|
||||
(rect.start.row / BLOCK, rect.end.row / BLOCK, rect.start.col / BLOCK, rect.end.col / BLOCK)
|
||||
}
|
||||
|
||||
fn block_count(rect: &RangeRef) -> u64 {
|
||||
let (r0, r1, c0, c1) = block_span(rect);
|
||||
u64::from(r1 - r0 + 1) * u64::from(c1 - c0 + 1)
|
||||
}
|
||||
|
||||
/// Whether registering `areas` in blocks costs less than checking each of them against each of
|
||||
/// `lookups` cells would: a few block entries cost about one containment check each. A tall or
|
||||
/// wide area is not registered at all (see `BIG_AREA_BLOCKS`) and so costs nothing here.
|
||||
pub(crate) fn indexing_pays<'a>(areas: impl ExactSizeIterator<Item = &'a RangeRef>, lookups: usize) -> bool {
|
||||
const BLOCK_COST: u64 = 16;
|
||||
let budget = (lookups as u64).saturating_mul(areas.len() as u64) / BLOCK_COST;
|
||||
let mut blocks = 0u64;
|
||||
for r in areas {
|
||||
let n = block_count(r);
|
||||
if n > BIG_AREA_BLOCKS {
|
||||
continue;
|
||||
}
|
||||
blocks = blocks.saturating_add(n);
|
||||
if blocks > budget {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
impl SpillIndex {
|
||||
/// Whether any area is indexed on `sheet`.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn has_sheet(&self, sheet: usize) -> bool {
|
||||
self.sheets.get(&sheet).is_some_and(|s| !s.entries.is_empty())
|
||||
}
|
||||
|
||||
/// Indexes the area an array of `rows` x `cols` anchored at `anchor` covers (replacing the
|
||||
/// one already indexed for that anchor).
|
||||
pub(crate) fn insert(&mut self, (sheet, anchor): Key, rows: usize, cols: usize) {
|
||||
let end =
|
||||
CellRef::new(anchor.row.saturating_add((rows as u32).saturating_sub(1)), anchor.col.saturating_add((cols as u32).saturating_sub(1)));
|
||||
self.insert_rect((sheet, anchor), RangeRef::new(anchor, end), rows == 0 || cols == 0);
|
||||
}
|
||||
|
||||
/// Indexes an area given as a range.
|
||||
pub(crate) fn insert_rect(&mut self, (sheet, anchor): Key, rect: RangeRef, empty: bool) {
|
||||
self.remove((sheet, anchor));
|
||||
let areas = self.sheets.entry(sheet).or_default();
|
||||
if block_count(&rect) > BIG_AREA_BLOCKS {
|
||||
areas.big.push(anchor);
|
||||
} else {
|
||||
let (r0, r1, c0, c1) = block_span(&rect);
|
||||
for br in r0..=r1 {
|
||||
for bc in c0..=c1 {
|
||||
areas.blocks.entry((br, bc)).or_default().push(anchor);
|
||||
}
|
||||
}
|
||||
}
|
||||
areas.entries.insert(anchor, Entry { rect, empty });
|
||||
}
|
||||
|
||||
pub(crate) fn remove(&mut self, (sheet, anchor): Key) {
|
||||
let Some(areas) = self.sheets.get_mut(&sheet) else { return };
|
||||
let Some(old) = areas.entries.remove(&anchor) else { return };
|
||||
if block_count(&old.rect) > BIG_AREA_BLOCKS {
|
||||
areas.big.retain(|a| *a != anchor);
|
||||
return;
|
||||
}
|
||||
let (r0, r1, c0, c1) = block_span(&old.rect);
|
||||
for br in r0..=r1 {
|
||||
for bc in c0..=c1 {
|
||||
if let Some(list) = areas.blocks.get_mut(&(br, bc)) {
|
||||
list.retain(|a| *a != anchor);
|
||||
if list.is_empty() {
|
||||
areas.blocks.remove(&(br, bc));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The areas that cover `c`.
|
||||
pub(crate) fn covering(&self, sheet: usize, c: CellRef) -> Cover {
|
||||
let Some(areas) = self.sheets.get(&sheet) else { return Cover::None };
|
||||
let in_block = areas.blocks.get(&(c.row / BLOCK, c.col / BLOCK)).map_or(&[][..], |l| l.as_slice());
|
||||
let mut found = Cover::None;
|
||||
for a in in_block.iter().chain(&areas.big) {
|
||||
if let Some(e) = areas.entries.get(a)
|
||||
&& !e.empty
|
||||
&& e.rect.contains(c)
|
||||
{
|
||||
found = match found {
|
||||
Cover::None => Cover::One(*a),
|
||||
_ => return Cover::Many,
|
||||
};
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// The anchors of the areas whose rectangle meets `range` (an empty area counts through its
|
||||
/// anchor cell), sorted by anchor.
|
||||
pub(crate) fn meeting(&self, sheet: usize, range: RangeRef, out: &mut Vec<CellRef>) {
|
||||
out.clear();
|
||||
let Some(areas) = self.sheets.get(&sheet) else { return };
|
||||
let (r0, r1, c0, c1) = block_span(&range);
|
||||
let touched = u64::from(r1 - r0 + 1) * u64::from(c1 - c0 + 1);
|
||||
if touched > 4 * areas.entries.len() as u64 + 16 {
|
||||
// A range wider than the areas are many: look at each area once.
|
||||
out.extend(areas.entries.iter().filter(|(_, e)| e.rect.intersects(&range)).map(|(a, _)| *a));
|
||||
} else {
|
||||
for br in r0..=r1 {
|
||||
for bc in c0..=c1 {
|
||||
if let Some(list) = areas.blocks.get(&(br, bc)) {
|
||||
out.extend(list.iter().copied().filter(|a| areas.entries.get(a).is_some_and(|e| e.rect.intersects(&range))));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.extend(areas.big.iter().copied().filter(|a| areas.entries.get(a).is_some_and(|e| e.rect.intersects(&range))));
|
||||
}
|
||||
out.sort_unstable();
|
||||
out.dedup();
|
||||
}
|
||||
|
||||
/// The rectangle indexed for `anchor` and whether it covers no cells.
|
||||
pub(crate) fn area(&self, sheet: usize, anchor: CellRef) -> Option<(RangeRef, bool)> {
|
||||
self.sheets.get(&sheet)?.entries.get(&anchor).map(|e| (e.rect, e.empty))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn at(r: u32, c: u32) -> CellRef {
|
||||
CellRef::new(r, c)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_the_areas_covering_a_cell_and_meeting_a_range() {
|
||||
let mut ix = SpillIndex::default();
|
||||
ix.insert((0, at(0, 0)), 40, 3);
|
||||
ix.insert((0, at(30, 1)), 1, 40);
|
||||
ix.insert((1, at(0, 0)), 5, 5);
|
||||
assert!(matches!(ix.covering(0, at(39, 2)), Cover::One(a) if a == at(0, 0)));
|
||||
assert!(matches!(ix.covering(0, at(30, 2)), Cover::Many));
|
||||
assert!(matches!(ix.covering(0, at(40, 0)), Cover::None));
|
||||
assert!(matches!(ix.covering(2, at(0, 0)), Cover::None));
|
||||
let mut hits = Vec::new();
|
||||
ix.meeting(0, RangeRef::new(at(30, 30), at(50, 60)), &mut hits);
|
||||
assert_eq!(hits, vec![at(30, 1)]);
|
||||
ix.meeting(0, RangeRef::new(at(0, 0), at(1_000_000, 16_000)), &mut hits);
|
||||
assert_eq!(hits, vec![at(0, 0), at(30, 1)]);
|
||||
ix.remove((0, at(30, 1)));
|
||||
assert!(matches!(ix.covering(0, at(30, 2)), Cover::One(_)));
|
||||
assert!(ix.has_sheet(1) && !ix.has_sheet(2));
|
||||
ix.insert((0, at(0, 0)), 1, 1);
|
||||
assert!(matches!(ix.covering(0, at(39, 2)), Cover::None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tall_or_wide_areas_are_not_registered_in_blocks() {
|
||||
let column = RangeRef::new(at(0, 0), at(1_048_575, 0));
|
||||
assert!(block_count(&column) > BIG_AREA_BLOCKS);
|
||||
let mut ix = SpillIndex::default();
|
||||
ix.insert_rect((0, at(0, 0)), column, false);
|
||||
assert!(ix.sheets[&0].blocks.is_empty());
|
||||
assert!(matches!(ix.covering(0, at(900_000, 0)), Cover::One(a) if a == at(0, 0)));
|
||||
assert!(matches!(ix.covering(0, at(900_000, 1)), Cover::None));
|
||||
let mut hits = Vec::new();
|
||||
ix.meeting(0, RangeRef::new(at(5, 0), at(6, 3)), &mut hits);
|
||||
assert_eq!(hits, vec![at(0, 0)]);
|
||||
ix.remove((0, at(0, 0)));
|
||||
assert!(matches!(ix.covering(0, at(900_000, 0)), Cover::None) && ix.sheets[&0].big.is_empty());
|
||||
// Registering a big area costs nothing (lookups test it directly), so a small paste still indexes next to many small areas.
|
||||
assert!(indexing_pays([column].iter(), 17));
|
||||
// Many small areas pay back for a paste of a hundred cells, but not for a handful.
|
||||
let small: Vec<RangeRef> = (0..1_000).map(|i| RangeRef::new(at(i * 3, 0), at(i * 3 + 2, 0))).collect();
|
||||
assert!(indexing_pays(small.iter(), 100));
|
||||
assert!(!indexing_pays(small.iter(), 17));
|
||||
}
|
||||
|
||||
/// The index against the old way: test every area for every lookup. Mixed shapes: small,
|
||||
/// thin and tall, wide, big (kept out of the blocks), empty, on two sheets, replaced and removed.
|
||||
#[test]
|
||||
fn matches_a_scan_of_every_area_on_mixed_shapes() {
|
||||
let mut seed = 0x2545_F491_4F6C_DD1Du64;
|
||||
let mut next = |n: u32| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
(seed >> 11) as u32 % n
|
||||
};
|
||||
let mut ix = SpillIndex::default();
|
||||
let mut model: std::collections::BTreeMap<(usize, CellRef), (RangeRef, bool)> = Default::default();
|
||||
for step in 0..3_000 {
|
||||
let sheet = next(2) as usize;
|
||||
let anchor = at(next(400) * 5, next(40) * 3);
|
||||
if next(4) == 0 {
|
||||
ix.remove((sheet, anchor));
|
||||
model.remove(&(sheet, anchor));
|
||||
} else {
|
||||
let (rows, cols) = match next(8) {
|
||||
0 => (0, 1 + next(5) as usize),
|
||||
1 => (1 + next(5) as usize, 0),
|
||||
2 => (5_000 + next(100_000) as usize, 1),
|
||||
3 => (1, 1 + next(40_000) as usize),
|
||||
4 => (200 + next(300) as usize, 40 + next(40) as usize),
|
||||
_ => (1 + next(60) as usize, 1 + next(40) as usize),
|
||||
};
|
||||
ix.insert((sheet, anchor), rows, cols);
|
||||
let end = at(anchor.row + (rows as u32).saturating_sub(1), anchor.col + (cols as u32).saturating_sub(1));
|
||||
model.insert((sheet, anchor), (RangeRef::new(anchor, end), rows == 0 || cols == 0));
|
||||
}
|
||||
if step % 7 != 0 {
|
||||
continue;
|
||||
}
|
||||
let sheet = next(3) as usize;
|
||||
for _ in 0..20 {
|
||||
let cell = at(next(2_100), next(130));
|
||||
let want: Vec<CellRef> =
|
||||
model.iter().filter(|((s, _), (r, e))| *s == sheet && !e && r.contains(cell)).map(|((_, a), _)| *a).collect();
|
||||
match ix.covering(sheet, cell) {
|
||||
Cover::None => assert!(want.is_empty(), "{cell:?}"),
|
||||
Cover::One(a) => assert_eq!(want, vec![a], "{cell:?}"),
|
||||
Cover::Many => assert!(want.len() > 1, "{cell:?}"),
|
||||
}
|
||||
let (r0, c0) = (next(2_100), next(130));
|
||||
let (tall, wide) = (if next(5) == 0 { 3_000 } else { 40 }, if next(5) == 0 { 130 } else { 20 });
|
||||
let range = RangeRef::new(at(r0, c0), at(r0 + next(tall), c0 + next(wide)));
|
||||
let want: Vec<CellRef> = model.iter().filter(|((s, _), (r, _))| *s == sheet && r.intersects(&range)).map(|((_, a), _)| *a).collect();
|
||||
let mut got = Vec::new();
|
||||
ix.meeting(sheet, range, &mut got);
|
||||
assert_eq!(got, want, "{range:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_area_without_cells_covers_none_but_is_met_at_its_anchor() {
|
||||
let mut ix = SpillIndex::default();
|
||||
ix.insert((0, at(3, 3)), 0, 4);
|
||||
assert!(matches!(ix.covering(0, at(3, 3)), Cover::None));
|
||||
let mut hits = Vec::new();
|
||||
ix.meeting(0, RangeRef::new(at(3, 3), at(3, 3)), &mut hits);
|
||||
assert_eq!(hits, vec![at(3, 3)]);
|
||||
}
|
||||
}
|
||||
+460
-2
@@ -1826,6 +1826,54 @@ fn trim_references() {
|
||||
assert_eq!(t.get("K20"), Value::Error(CellError::Calc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_of_an_empty_cell_stays_blank() {
|
||||
// #374: B1 (the value for key "k") is empty.
|
||||
let mut t = T::new();
|
||||
t.set("A1", "k");
|
||||
t.set("A2", "j");
|
||||
t.set("B2", "v");
|
||||
for (at, f, want) in [
|
||||
("D2", "=\"x\"&VLOOKUP(\"k\",A1:B2,2,0)&\"y\"", Value::from("xy")),
|
||||
("D3", "=CONCATENATE(\"x\",VLOOKUP(\"k\",A1:B2,2,0),\"y\")", Value::from("xy")),
|
||||
("D4", "=ISBLANK(XLOOKUP(\"k\",A1:A2,B1:B2))", Value::Bool(true)),
|
||||
("D5", "=\"x\"&XLOOKUP(\"k\",A1:A2,B1:B2)&\"y\"", Value::from("xy")),
|
||||
// A bare lookup still shows 0, like a reference to an empty cell.
|
||||
("D8", "=VLOOKUP(\"k\",A1:B2,2,0)", Value::Number(0.0)),
|
||||
// Arithmetic still reads the blank as 0.
|
||||
("D9", "=VLOOKUP(\"k\",A1:B2,2,0)+1", Value::Number(1.0)),
|
||||
// A blank lookup given as an argument is still given, not omitted.
|
||||
("D10", "=FILTER(1,FALSE,VLOOKUP(\"k\",A1:B2,2,FALSE))", Value::Number(0.0)),
|
||||
("D11", "=XLOOKUP(\"z\",A1:A2,B1:B2,VLOOKUP(\"k\",A1:B2,2,0))", Value::Number(0.0)),
|
||||
// The same holds per element when the call is lifted over an array of blank lookups.
|
||||
("D12", "=CEILING.MATH(6.7,VLOOKUP(\"k\",A1:B2,2,0))", Value::Number(0.0)),
|
||||
("D13", "=SUM(CEILING.MATH(6.7,VLOOKUP({\"k\",\"k\"},A1:B2,2,0)))", Value::Number(0.0)),
|
||||
("D14", "=FLOOR.MATH(6.7,VLOOKUP(\"k\",A1:B2,2,0))", Value::Number(0.0)),
|
||||
("D15", "=SUM(FLOOR.MATH(6.7,VLOOKUP({\"k\",\"k\"},A1:B2,2,0)))", Value::Number(0.0)),
|
||||
] {
|
||||
t.set(at, f);
|
||||
assert_eq!(t.get(at), want, "{f}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_lookup_values_match_excel_windows_observation() {
|
||||
// Excel 365 Windows 2609, build 16.0.20430.20032, black-box check 2026-10-13:
|
||||
// A1="k", B1 and A2 genuinely empty.
|
||||
// All three value lookups preserve blankness inside ISBLANK/concatenation.
|
||||
let mut t = T::new();
|
||||
t.set("A1", "k");
|
||||
for (at, f, want) in [
|
||||
("D1", "=ISBLANK(VLOOKUP(\"k\",A1:B1,2,FALSE))", Value::Bool(true)),
|
||||
("D2", "=ISBLANK(HLOOKUP(\"k\",A1:A2,2,FALSE))", Value::Bool(true)),
|
||||
("D3", "=ISBLANK(LOOKUP(\"k\",A1:A1,B1:B1))", Value::Bool(true)),
|
||||
("D4", "=\"x\"&HLOOKUP(\"k\",A1:A2,2,FALSE)&\"y\"", Value::from("xy")),
|
||||
] {
|
||||
t.set(at, f);
|
||||
assert_eq!(t.get(at), want, "{f}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_width_is_the_column_width_dialog_value_rounded() {
|
||||
// #735: Excel 365 values. Widths in px as `home.columnWidth {"chars"}` sets them
|
||||
@@ -1922,6 +1970,54 @@ fn offset_extreme_arguments_return_ref() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `@` intersects a reference with the formula's row (a column range) or column (a row range);
|
||||
/// a 2D range both (circular when it includes the formula's cell); a range the formula does not
|
||||
/// line up with gives #VALUE!. An array gives its top-left value. Legacy formulas read from files use it where Excel intersects implicitly.
|
||||
#[test]
|
||||
fn implicit_intersection_operator() {
|
||||
let mut t = T::new();
|
||||
for (at, v) in [("A1", "1"), ("A2", "2"), ("A3", "3"), ("A5", "4"), ("B5", "5"), ("C5", "6")] {
|
||||
t.set(at, v);
|
||||
}
|
||||
t.set("B2", "=TANH(@A1:A3)");
|
||||
t.set("B7", "=TANH(@A5:C5)");
|
||||
t.set("B3", "=@A1:C3");
|
||||
t.set("B9", "=@A1:A3");
|
||||
t.set("E9", "=@A5:C5");
|
||||
t.set("D1", "=@{7,8;9,10}");
|
||||
t.set("D2", "=@A2");
|
||||
t.set("D3", "=SUM(@A1:A3)");
|
||||
assert_eq!(t.num("B2"), 2f64.tanh());
|
||||
assert_eq!(t.num("B7"), 5f64.tanh());
|
||||
for at in ["B9", "E9"] {
|
||||
assert_eq!(t.get(at), Value::Error(CellError::Value), "{at}");
|
||||
}
|
||||
// A 2D range that includes the formula's own cell is a circular reference (Excel shows 0).
|
||||
assert_eq!(t.get("B3"), Value::Error(CellError::Circ));
|
||||
// From another sheet a 2D range intersects on both row and column; outside it is #VALUE!.
|
||||
t.wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("Other")));
|
||||
for (at, input) in [("B2", "=@Sheet1!A1:C3"), ("C1", "=@Sheet1!A1:C3"), ("B9", "=@Sheet1!A1:C3"), ("E2", "=@Sheet1!A1:C3")] {
|
||||
t.wb.sheet_mut(1).unwrap().cells.set(c(at), Cell::formula(Formula::new(input)));
|
||||
}
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
let other = |at: &str| t.wb.sheet(1).unwrap().value(c(at));
|
||||
assert_eq!(other("B2"), Value::Number(2f64.tanh())); // Sheet1!B2
|
||||
assert_eq!(other("C1"), Value::Number(0.0));
|
||||
assert_eq!(other("B9"), Value::Error(CellError::Value));
|
||||
assert_eq!(other("E2"), Value::Error(CellError::Value));
|
||||
assert_eq!(t.num("D1"), 7.0);
|
||||
assert_eq!(t.num("D2"), 2.0);
|
||||
assert_eq!(t.num("D3"), 3.0);
|
||||
// An array result shows its top-left value (T of a reference reads its top-left cell).
|
||||
t.set("A11", "x");
|
||||
t.set("C11", "y");
|
||||
t.set("E1", "=@T(A11:C11)");
|
||||
assert_eq!(t.get("E1"), Value::text("x"));
|
||||
// Without `@` (a dynamic-array formula) the same formula spills.
|
||||
t.set("F1", "=TANH(A1:A3)");
|
||||
assert_eq!((t.num("F1"), t.num("F2"), t.num("F3")), (1f64.tanh(), 2f64.tanh(), 3f64.tanh()));
|
||||
}
|
||||
|
||||
/// "Set precision as displayed": results are rounded to their cell's format before they are
|
||||
/// stored and before other formulas read them. Values observed in Excel (COM,
|
||||
/// `Workbook.PrecisionAsDisplayed = True`) with the same synthetic formulas.
|
||||
@@ -1987,7 +2083,6 @@ fn precision_as_displayed_rounds_results_by_cell_format() {
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
assert_eq!(t.num("A2"), (-130.0 + 657.41) * 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analysis_toolpak_value_params_reject_multi_cell_references() {
|
||||
// Excel 365 (observed): EOMONTH, WORKDAY, COUPDAYS, BIN2DEC... give #VALUE! for a reference to more
|
||||
@@ -2072,7 +2167,6 @@ fn analysis_toolpak_value_params_reject_multi_cell_references() {
|
||||
t.set("H5", "");
|
||||
}
|
||||
}
|
||||
|
||||
/// A formula reading one further down (or on a later sheet) is calculated on demand, inside the
|
||||
/// formula that reads it. Chains of thousands of such formulas, common in financial models, used
|
||||
/// to overflow the stack (or give `#CIRC!` past 2,000 levels); they now calculate in full.
|
||||
@@ -2379,6 +2473,354 @@ fn loan_schedule_residue_keeps_excels_sign() {
|
||||
assert_eq!(t.get("P7"), Value::Text("".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_a_cell_unblocks_the_arrays_above_and_left_of_it() {
|
||||
let mut t = T::new();
|
||||
for r in 1..=50 {
|
||||
t.set(&format!("D{r}"), &format!("={r}*2"));
|
||||
}
|
||||
t.set("B2", "7");
|
||||
t.set("B1", "=SEQUENCE(3)");
|
||||
t.set("C1", "=SEQUENCE(1,3)");
|
||||
t.set("A20", "=B1+1");
|
||||
assert_eq!(t.get("B1"), Value::Error(CellError::Spill));
|
||||
assert_eq!(t.get("A20"), Value::Error(CellError::Spill));
|
||||
// Emptied: the array above it spills.
|
||||
t.wb.sheet_mut(0).unwrap().cells.remove(c("B2"));
|
||||
t.calc.cells_changed(&mut t.wb, &[(0, c("B2"))]);
|
||||
assert_eq!(t.num("B3"), 3.0);
|
||||
assert_eq!(t.num("A20"), 2.0);
|
||||
// Typing over the area blocks it again; far from it, nothing is disturbed.
|
||||
t.set("B3", "9");
|
||||
assert_eq!(t.get("B1"), Value::Error(CellError::Spill));
|
||||
t.set("D50", "1");
|
||||
assert_eq!(t.get("B1"), Value::Error(CellError::Spill));
|
||||
assert_eq!(t.num("D49"), 98.0);
|
||||
// Clearing it again frees the array.
|
||||
t.set("B3", "");
|
||||
assert_eq!(t.num("B3"), 3.0);
|
||||
}
|
||||
|
||||
/// A sheet with `#SPILL!` cells in rows 0..64, columns 100..16384 (as blocked arrays leave), none
|
||||
/// near column A.
|
||||
fn spill_error_field() -> T {
|
||||
let mut t = T::new();
|
||||
let sh = t.wb.sheet_mut(0).unwrap();
|
||||
for r in 0..64 {
|
||||
for col in 100..16_384 {
|
||||
sh.cells.set(CellRef::new(r, col), Cell::value(Value::Error(CellError::Spill)));
|
||||
}
|
||||
}
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_far_left_of_many_spill_errors_does_not_disturb_them() {
|
||||
let mut t = spill_error_field();
|
||||
t.set("A65", "5");
|
||||
assert_eq!(t.num("A65"), 5.0);
|
||||
assert_eq!(t.get("CW1"), Value::Error(CellError::Spill));
|
||||
assert_eq!(t.get("XFD64"), Value::Error(CellError::Spill));
|
||||
// A blocked formula inside the window is still found when a cell to its right is cleared.
|
||||
t.set("DA66", "7");
|
||||
t.set("CZ66", "=SEQUENCE(1,3)");
|
||||
assert_eq!(t.get("CZ66"), Value::Error(CellError::Spill));
|
||||
t.wb.sheet_mut(0).unwrap().cells.remove(c("DA66"));
|
||||
t.calc.cells_changed(&mut t.wb, &[(0, c("DA66"))]);
|
||||
assert_eq!(t.num("DA66"), 2.0);
|
||||
}
|
||||
|
||||
/// Probe: single-cell edits in column A below a block of `#SPILL!` cells to its right.
|
||||
/// `cargo test -p gridcraft-calc --release -- --ignored --nocapture spill_error_field_probe`
|
||||
#[test]
|
||||
#[ignore]
|
||||
#[allow(clippy::disallowed_methods)]
|
||||
fn spill_error_field_probe() {
|
||||
let mut t = spill_error_field();
|
||||
let mut best = std::time::Duration::MAX;
|
||||
for i in 0..9 {
|
||||
let start = std::time::Instant::now();
|
||||
for _ in 0..100 {
|
||||
t.set("A65", &format!("{i}"));
|
||||
}
|
||||
best = best.min(start.elapsed());
|
||||
}
|
||||
eprintln!("100 edits of A65 beside 1,040,000 spill errors: best of 9 = {best:?}");
|
||||
}
|
||||
|
||||
/// A full-height column spill (inside the 10,000,000-cell cap) and a paste of 17 cells elsewhere:
|
||||
/// the paste must not register the spill's 65,536 blocks, and a paste over the spill must still
|
||||
/// block it.
|
||||
fn tall_spill_book() -> T {
|
||||
let mut t = T::new();
|
||||
t.set("A1", "=SEQUENCE(1048576)");
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
assert_eq!(t.num("A1048576"), 1_048_576.0);
|
||||
t
|
||||
}
|
||||
|
||||
fn paste_into(t: &mut T, cells: &[(u32, u32)]) {
|
||||
let changed: Vec<(usize, CellRef)> = cells.iter().map(|&(r, c)| (0, CellRef::new(r, c))).collect();
|
||||
for k in &changed {
|
||||
t.wb.sheet_mut(0).unwrap().cells.set(k.1, Cell::value(Value::Number(1.0)));
|
||||
}
|
||||
t.calc.cells_changed(&mut t.wb, &changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paste_beside_a_tall_spill_leaves_it_alone_and_one_over_it_blocks_it() {
|
||||
let mut t = tall_spill_book();
|
||||
let beside: Vec<(u32, u32)> = (0..17).map(|i| (i, 5)).collect();
|
||||
paste_into(&mut t, &beside);
|
||||
assert_eq!(t.num("A1048576"), 1_048_576.0);
|
||||
let over: Vec<(u32, u32)> = (0..17).map(|i| (500_000 + i, if i == 9 { 0 } else { 5 })).collect();
|
||||
paste_into(&mut t, &over);
|
||||
assert_eq!(t.get("A1"), Value::Error(CellError::Spill));
|
||||
}
|
||||
|
||||
/// Probe: `cells_changed` for a 17-cell paste in column F beside the tall spill.
|
||||
/// `cargo test -p gridcraft-calc --release -- --ignored --nocapture tall_spill_paste_probe`
|
||||
#[test]
|
||||
#[ignore]
|
||||
#[allow(clippy::disallowed_methods)]
|
||||
fn tall_spill_paste_probe() {
|
||||
let mut t = tall_spill_book();
|
||||
let mut best = std::time::Duration::MAX;
|
||||
for round in 0..9u32 {
|
||||
let beside: Vec<(u32, u32)> = (0..17).map(|i| (round * 20 + i, 5)).collect();
|
||||
let start = std::time::Instant::now();
|
||||
paste_into(&mut t, &beside);
|
||||
best = best.min(start.elapsed());
|
||||
}
|
||||
eprintln!("tall spill, 17-cell paste beside it: best of 9 = {best:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_spills_are_read_and_blocked_by_a_paste() {
|
||||
let mut t = T::new();
|
||||
// 40 arrays of 3 rows side by side in rows 1-3, and a reader of a range over two of them.
|
||||
for i in 0..40u32 {
|
||||
let anchor = CellRef::new(0, 2 * i);
|
||||
t.wb.sheet_mut(0).unwrap().cells.set(anchor, Cell::formula(Formula::new(&format!("=SEQUENCE(3,1,{})", 10 * i))));
|
||||
}
|
||||
t.set("A10", "=SUM(C1:F3)");
|
||||
t.set("A12", "=COUNT(C1:F3)");
|
||||
t.set("A11", "=C2*1");
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
assert_eq!(t.num("A10"), (10 + 11 + 12 + 20 + 21 + 22) as f64);
|
||||
assert_eq!(t.num("A11"), 11.0);
|
||||
assert_eq!(t.num("A12"), 6.0);
|
||||
// Pasting 20 cells (more than one spill's width worth, so the changed set is indexed) over
|
||||
// the second row of every other array blocks those arrays; the rest are unchanged.
|
||||
let changed: Vec<(usize, CellRef)> = (0..20u32).map(|i| (0, CellRef::new(1, 4 * i))).collect();
|
||||
for k in &changed {
|
||||
t.wb.sheet_mut(0).unwrap().cells.set(k.1, Cell::value(Value::Number(1.0)));
|
||||
}
|
||||
t.calc.cells_changed(&mut t.wb, &changed);
|
||||
assert_eq!(t.get("E1"), Value::Error(CellError::Spill));
|
||||
assert_eq!(t.get("C1"), Value::Number(10.0));
|
||||
assert_eq!(t.get("G2"), Value::Number(31.0));
|
||||
assert_eq!(t.get("A10"), Value::Error(CellError::Spill));
|
||||
assert_eq!(t.num("A12"), 4.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paste_over_small_and_tall_spills_blocks_those_it_touches() {
|
||||
let mut t = T::new();
|
||||
for i in 0..40u32 {
|
||||
t.wb.sheet_mut(0).unwrap().cells.set(CellRef::new(0, 2 * i), Cell::formula(Formula::new("=SEQUENCE(3)")));
|
||||
}
|
||||
t.wb.sheet_mut(0).unwrap().cells.set(CellRef::new(0, 200), Cell::formula(Formula::new("=SEQUENCE(1048576)")));
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
// 20 cells: the middle row of the small arrays in columns 0, 8, 16 ... 72 (columns 80 and up hit
|
||||
// nothing), and one deep inside the tall one.
|
||||
let mut cells: Vec<(u32, u32)> = (0..19u32).map(|i| (1, 8 * i)).collect();
|
||||
cells.push((700_000, 200));
|
||||
paste_into(&mut t, &cells);
|
||||
assert_eq!(t.get("A1"), Value::Error(CellError::Spill));
|
||||
assert_eq!(t.get("GS1"), Value::Error(CellError::Spill));
|
||||
assert_eq!(t.num("C1"), 1.0);
|
||||
assert_eq!(t.num("C3"), 3.0);
|
||||
assert_eq!(t.get("I1"), Value::Error(CellError::Spill));
|
||||
assert_eq!(t.num("E3"), 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_big_paste_on_one_sheet_and_one_cell_over_many_spills_on_another() {
|
||||
let mut t = T::new();
|
||||
t.wb.sheets.push(std::sync::Arc::new(gridcraft_model::Sheet::new("Data")));
|
||||
for i in 0..1_500u32 {
|
||||
t.wb.sheet_mut(1).unwrap().cells.set(CellRef::new(4 * i, 0), Cell::formula(Formula::new(&format!("=SEQUENCE(3,1,{})", 10 * i))));
|
||||
}
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
assert_eq!(t.wb.sheet(1).unwrap().value(CellRef::new(4 * 700 + 2, 0)), Value::Number(7002.0));
|
||||
// 5000 cells pasted on the first sheet, one over the middle of the 701st array on the second.
|
||||
let mut changed: Vec<(usize, CellRef)> = (0..5_000u32).map(|i| (0, CellRef::new(i / 50, i % 50))).collect();
|
||||
changed.push((1, CellRef::new(4 * 700 + 1, 0)));
|
||||
for k in &changed {
|
||||
t.wb.sheet_mut(k.0).unwrap().cells.set(k.1, Cell::value(Value::Number(1.0)));
|
||||
}
|
||||
t.calc.cells_changed(&mut t.wb, &changed);
|
||||
let data = t.wb.sheet(1).unwrap();
|
||||
assert_eq!(data.value(CellRef::new(4 * 700, 0)), Value::Error(CellError::Spill));
|
||||
assert_eq!(data.value(CellRef::new(4 * 699 + 2, 0)), Value::Number(6992.0));
|
||||
assert_eq!(data.value(CellRef::new(4 * 701 + 2, 0)), Value::Number(7012.0));
|
||||
}
|
||||
|
||||
/// `SUM`, `COUNT` and `AVERAGE` over blocks of constants share one pass-wide answer per block, and a
|
||||
/// running total extends the shorter block's. Every result must equal the left-to-right sum of the
|
||||
/// cells, bit for bit, with text, blanks, errors, formulas and a nonzero start in the mix.
|
||||
#[test]
|
||||
fn aggregates_over_ranges_match_a_plain_fold_bit_for_bit() {
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
fn next(&mut self, n: u64) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
self.0 % n
|
||||
}
|
||||
}
|
||||
let nums = [0.1, 0.2, 0.3, 1e16, -1e16, 1.0, 3.7, -0.0, 12345.678, 1e-9, 7.0];
|
||||
let rows = 40u32;
|
||||
let mut rng = Rng(0x9E37_79B9_7F4A_7C15);
|
||||
let mut wb = Workbook::new();
|
||||
let fresh = |rng: &mut Rng, plain: bool| match rng.next(if plain { 8 } else { 14 }) {
|
||||
0 => Cell::value(Value::text("t")),
|
||||
1 => Cell::default(),
|
||||
2 => Cell::value(Value::Bool(true)),
|
||||
3 if !plain => Cell::value(Value::Error(CellError::NA)),
|
||||
4 if !plain => Cell::formula(Formula::new("=A1*2")),
|
||||
5 if !plain => Cell::formula(Formula::new("=\"x\"&B2")),
|
||||
_ => Cell::value(Value::Number(nums[rng.next(nums.len() as u64) as usize])),
|
||||
};
|
||||
// Columns A:C mostly numbers; E holds a formula column and some errors only in a few rows.
|
||||
for r in 0..rows {
|
||||
for col in 0..3 {
|
||||
let plain = !(r % 13 == 7 && col == 1);
|
||||
wb.sheet_mut(0).unwrap().cells.set(CellRef::new(r, col), fresh(&mut rng, plain));
|
||||
}
|
||||
}
|
||||
// Formulas in column H and up: (function, optional leading number, blocks).
|
||||
type Oracle<'a> = (CellRef, &'a str, Option<f64>, Vec<(u32, u32, u32, u32)>);
|
||||
let mut oracle: Vec<Oracle> = vec![];
|
||||
let funcs = ["SUM", "COUNT", "AVERAGE"];
|
||||
let mut at = 0;
|
||||
let mut add = |wb: &mut Workbook, f: &'static str, lead: Option<f64>, blocks: Vec<(u32, u32, u32, u32)>| {
|
||||
let args: Vec<String> = blocks
|
||||
.iter()
|
||||
.map(|&(r0, c0, r1, c1)| format!("{}{}:{}{}", (b'A' + c0 as u8) as char, r0 + 1, (b'A' + c1 as u8) as char, r1 + 1))
|
||||
.collect();
|
||||
let lead_s = lead.map(|l| format!("{l:e},")).unwrap_or_default();
|
||||
let cell = CellRef::new(at % rows, 7 + at / rows);
|
||||
wb.sheet_mut(0).unwrap().cells.set(cell, Cell::formula(Formula::new(&format!("={f}({lead_s}{})", args.join(",")))));
|
||||
oracle.push((cell, f, lead, blocks));
|
||||
at += 1;
|
||||
};
|
||||
// Running totals over a clean column and over a column with an error further down.
|
||||
for r in 0..rows {
|
||||
add(&mut wb, "SUM", None, vec![(0, 0, r, 0)]);
|
||||
add(&mut wb, "SUM", None, vec![(0, 1, r, 1)]);
|
||||
add(&mut wb, "COUNT", None, vec![(0, 0, r, 2)]);
|
||||
}
|
||||
for _ in 0..120 {
|
||||
let (r0, r1) = (rng.next(rows as u64) as u32, rng.next(rows as u64) as u32);
|
||||
let (c0, c1) = (rng.next(3) as u32, rng.next(3) as u32);
|
||||
let n = 1 + rng.next(2) as usize;
|
||||
let blocks = (0..n).map(|_| (r0.min(r1), c0.min(c1), r0.max(r1), c0.max(c1))).collect();
|
||||
let lead = [None, Some(1e16), Some(0.5), Some(2.0)][rng.next(4) as usize];
|
||||
let f = funcs[rng.next(3) as usize];
|
||||
add(&mut wb, f, lead, blocks);
|
||||
}
|
||||
let check = |wb: &Workbook, when: &str| {
|
||||
let sh = wb.sheet(0).unwrap();
|
||||
for (cell, f, lead, blocks) in &oracle {
|
||||
let (mut sum, mut count, mut error) = (lead.unwrap_or(0.0), u64::from(lead.is_some()), None);
|
||||
for &(r0, c0, r1, c1) in blocks {
|
||||
for r in r0..=r1 {
|
||||
for col in c0..=c1 {
|
||||
match sh.value(CellRef::new(r, col)) {
|
||||
Value::Number(n) => {
|
||||
sum += n;
|
||||
count += 1;
|
||||
}
|
||||
Value::Error(e) if *f != "COUNT" => error = error.or(Some(e)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let want = match (error, *f) {
|
||||
(Some(e), _) => Value::Error(e),
|
||||
(_, "SUM") => Value::number(sum),
|
||||
(_, "COUNT") => Value::number(count as f64),
|
||||
(_, _) if count == 0 => Value::Error(CellError::Div0),
|
||||
_ => Value::number(sum / count as f64),
|
||||
};
|
||||
let got = sh.value(*cell);
|
||||
let same = match (&got, &want) {
|
||||
(Value::Number(a), Value::Number(b)) => a.to_bits() == b.to_bits(),
|
||||
(a, b) => a == b,
|
||||
};
|
||||
assert!(same, "{when}: {:?} = {:?}, plain fold {:?}", cell, got, want);
|
||||
}
|
||||
};
|
||||
let mut calc = Calc::new();
|
||||
calc.rebuild(&wb);
|
||||
calc.recalc_all(&mut wb);
|
||||
check(&wb, "full recalc");
|
||||
for round in 0..30 {
|
||||
let mut changed = vec![];
|
||||
for _ in 0..1 + rng.next(4) {
|
||||
let at = CellRef::new(rng.next(rows as u64) as u32, rng.next(3) as u32);
|
||||
wb.sheet_mut(0).unwrap().cells.set(at, fresh(&mut rng, round % 2 == 0));
|
||||
changed.push((0, at));
|
||||
}
|
||||
calc.cells_changed(&mut wb, &changed);
|
||||
check(&wb, &format!("edit round {round}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// `rebuild` and `cells_changed` read formulas through `Formula::with_expr`, which borrows the
|
||||
/// cached tree or parses on the fly. The dependency graph must come out the same for formulas that
|
||||
/// carry their tree and for formulas that only carry their text (as after loading a file).
|
||||
#[test]
|
||||
fn graph_built_from_text_only_formulas_matches_cached() {
|
||||
fn run(cached: bool) -> Vec<Value> {
|
||||
let mut t = T::new();
|
||||
t.set("A1", "2");
|
||||
for (at, f) in [("B1", "=A1*10"), ("C1", "=B1+A1"), ("D1", "=SUM(A1:C1)"), ("E1", "=NOPE(")] {
|
||||
// E1 does not parse, so it has no tree either way and only covers the `None` branch.
|
||||
let mut formula = Formula::new(f);
|
||||
if !cached {
|
||||
formula.expr = None;
|
||||
}
|
||||
t.wb.sheet_mut(0).unwrap().cells.set(c(at), Cell::formula(formula));
|
||||
}
|
||||
t.calc.rebuild(&t.wb);
|
||||
t.calc.recalc_all(&mut t.wb);
|
||||
let mut out: Vec<Value> = ["B1", "C1", "D1", "E1"].iter().map(|a| t.get(a)).collect();
|
||||
// Editing the input must reach the dependents through the rebuilt graph.
|
||||
t.set("A1", "5");
|
||||
out.extend(["B1", "C1", "D1"].iter().map(|a| t.get(a)));
|
||||
// A text-only formula that is rewritten through `cells_changed` joins the graph too.
|
||||
let mut formula = Formula::new("=A1+100");
|
||||
if !cached {
|
||||
formula.expr = None;
|
||||
}
|
||||
t.wb.sheet_mut(0).unwrap().cells.set(c("F1"), Cell::formula(formula));
|
||||
t.calc.cells_changed(&mut t.wb, &[(0, c("F1"))]);
|
||||
t.set("A1", "7");
|
||||
out.push(t.get("F1"));
|
||||
out
|
||||
}
|
||||
let cached = run(true);
|
||||
assert_eq!(cached, run(false));
|
||||
assert_eq!(cached[4], Value::Number(50.0));
|
||||
assert_eq!(cached[7], Value::Number(107.0));
|
||||
}
|
||||
|
||||
/// Makes `at` a Python cell showing `cached` (its value before this calculation), with `result`
|
||||
/// as its kept result, and returns the cell's key for `Calc::cells_changed`.
|
||||
fn plant_python(t: &mut T, at: &str, cached: Value, result: Value) -> (usize, CellRef) {
|
||||
@@ -2438,3 +2880,19 @@ fn formulas_read_a_dirty_python_cell_after_it_is_laid_out() {
|
||||
assert_eq!((t.num("B1"), t.num("B2")), (5.0, 6.0));
|
||||
assert_eq!((t.num("A1"), t.num("A2"), t.num("A3")), (5.0, 100.0, 111.0));
|
||||
}
|
||||
|
||||
/// A computed criterion whose formula runs a database function over the same criteria counts as
|
||||
/// not met inside itself: before, each record's check ran the function again for every record, at
|
||||
/// every level, so the work grew exponentially with the nesting allowed.
|
||||
#[test]
|
||||
fn computed_criterion_that_reenters_itself_is_not_met() {
|
||||
let mut t = T::new();
|
||||
for (r, (name, age)) in [("name", "age"), ("a", "1"), ("b", "2"), ("c", "3"), ("d", "4"), ("e", "5")].iter().enumerate() {
|
||||
t.set(&format!("F{}", r + 2), name);
|
||||
t.set(&format!("G{}", r + 2), age);
|
||||
}
|
||||
t.set("N46", "Formula");
|
||||
t.set("N47", "=DCOUNT($F$2:$G$7,\"age\",$N$46:$N$47)>0");
|
||||
t.set("B60", "=DCOUNT($F$2:$G$7,\"age\",N46:N47)");
|
||||
assert_eq!(t.num("B60"), 0.0);
|
||||
}
|
||||
|
||||
@@ -25,9 +25,11 @@ impl Scale {
|
||||
(0..=n)
|
||||
.map(|i| {
|
||||
let v = self.min + i as f64 * self.step;
|
||||
// Snap away accumulated float error.
|
||||
// Snap away accumulated float error, but only onto a multiple of the step that is
|
||||
// really that close: a `min` off the step grid must keep its own ticks.
|
||||
let snapped = (v / self.step).round() * self.step;
|
||||
if snapped.abs() < self.step * 1e-9 { 0.0 } else { snapped }
|
||||
let v = if (snapped - v).abs() < self.step * 1e-9 { snapped } else { v };
|
||||
if v.abs() < self.step * 1e-9 { 0.0 } else { v }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -107,11 +109,6 @@ pub fn nice_scale(lo: f64, hi: f64, force_zero: bool) -> Scale {
|
||||
Scale { min, max, step }
|
||||
}
|
||||
|
||||
/// Exact fixed scale (100% stacked axes).
|
||||
pub(crate) fn fixed_scale(min: f64, max: f64, step: f64) -> Scale {
|
||||
Scale { min, max, step }
|
||||
}
|
||||
|
||||
/// A number format prepared once and reused for many labels.
|
||||
pub(crate) struct Fmt {
|
||||
general: bool,
|
||||
|
||||
@@ -7,6 +7,20 @@ use crate::{Prim, Rgba};
|
||||
/// Largest image we allocate (pixels).
|
||||
const MAX_PIXELS: u64 = 64 * 1024 * 1024;
|
||||
const SUB: usize = 4;
|
||||
/// Scan-conversion work one [`rasterize`] call may do: edge tests (an active edge at one
|
||||
/// sub-sample) plus covered pixels blended. Edges are only tested on the rows they cross, but a
|
||||
/// caller can still pass enough geometry to keep the rasteriser busy for minutes on a small
|
||||
/// image; once the allowance is spent the remaining fills are skipped. 2^28 is about a second,
|
||||
/// far beyond any chart.
|
||||
const MAX_SCAN_WORK: u64 = 1 << 28;
|
||||
/// Most points one filled path may have: more is skipped before any edge is built. Chart
|
||||
/// renderers cap and decimate their paths far below this; it only bounds direct callers.
|
||||
const MAX_FILL_POINTS: usize = 1 << 22;
|
||||
|
||||
/// Most points one stroked polyline may have: more is skipped before any segment path is built
|
||||
/// (each segment becomes its own quad). Charts hold at most 100,000 points per series and draw far
|
||||
/// fewer; it only bounds direct callers.
|
||||
const MAX_STROKE_POINTS: usize = 1 << 17;
|
||||
|
||||
type Path = Vec<Vec<[f32; 2]>>;
|
||||
|
||||
@@ -15,6 +29,8 @@ struct Canvas {
|
||||
h: usize,
|
||||
buf: Vec<u8>,
|
||||
acc: Vec<f32>,
|
||||
/// Scan-conversion work left (see [`MAX_SCAN_WORK`]).
|
||||
work: u64,
|
||||
}
|
||||
|
||||
impl Canvas {
|
||||
@@ -41,7 +57,12 @@ impl Canvas {
|
||||
|
||||
/// Fills a multi-contour path with the even-odd rule.
|
||||
fn fill(&mut self, path: &Path, color: Rgba) {
|
||||
let mut edges: Vec<[f32; 4]> = Vec::new();
|
||||
// Out of work, or more geometry than the allowance could ever scan: no edge list is built.
|
||||
if self.work == 0 || path.iter().map(Vec::len).fold(0usize, usize::saturating_add) > MAX_FILL_POINTS {
|
||||
return;
|
||||
}
|
||||
// Each edge as [top y, bottom y, x0, y0, x1, y1].
|
||||
let mut edges: Vec<[f32; 6]> = Vec::new();
|
||||
let (mut minx, mut miny, mut maxx, mut maxy) = (f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY);
|
||||
for c in path {
|
||||
if c.len() < 3 || !c.iter().all(|p| p[0].is_finite() && p[1].is_finite()) {
|
||||
@@ -54,7 +75,7 @@ impl Canvas {
|
||||
miny = miny.min(p[1]);
|
||||
maxy = maxy.max(p[1]);
|
||||
if p[1] != q[1] {
|
||||
edges.push([p[0], p[1], q[0], q[1]]);
|
||||
edges.push([p[1].min(q[1]), p[1].max(q[1]), p[0], p[1], q[0], q[1]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,8 +93,28 @@ impl Canvas {
|
||||
if self.acc.len() < bw + 1 {
|
||||
self.acc.resize(bw + 1, 0.0);
|
||||
}
|
||||
// Active-edge list: edges join when the scan reaches their top and leave past their
|
||||
// bottom, so each row tests only the edges crossing it instead of every edge.
|
||||
edges.sort_by(|a, b| a[0].total_cmp(&b[0]));
|
||||
let mut pending = edges.iter().peekable();
|
||||
let mut active: Vec<[f32; 6]> = Vec::new();
|
||||
let mut xs: Vec<f32> = Vec::new();
|
||||
for py in y0..y1 {
|
||||
while let Some(e) = pending.next_if(|e| e[0] < py as f32 + 1.0) {
|
||||
active.push(*e);
|
||||
}
|
||||
active.retain(|e| e[1] > py as f32);
|
||||
if active.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// The row's edge tests and its `bw` accumulator clears and blends, charged before any of
|
||||
// them, so rows with no coverage still count.
|
||||
let cost = (active.len() * SUB) as u64 + bw as u64;
|
||||
if cost > self.work {
|
||||
self.work = 0;
|
||||
return;
|
||||
}
|
||||
self.work -= cost;
|
||||
for v in self.acc.iter_mut().take(bw) {
|
||||
*v = 0.0;
|
||||
}
|
||||
@@ -81,10 +122,9 @@ impl Canvas {
|
||||
for s in 0..SUB {
|
||||
let sy = py as f32 + (s as f32 + 0.5) / SUB as f32;
|
||||
xs.clear();
|
||||
for e in &edges {
|
||||
let (ya, yb) = if e[1] < e[3] { (e[1], e[3]) } else { (e[3], e[1]) };
|
||||
if sy >= ya && sy < yb {
|
||||
xs.push(e[0] + (sy - e[1]) * (e[2] - e[0]) / (e[3] - e[1]));
|
||||
for e in &active {
|
||||
if sy >= e[0] && sy < e[1] {
|
||||
xs.push(e[2] + (sy - e[3]) * (e[4] - e[2]) / (e[5] - e[3]));
|
||||
}
|
||||
}
|
||||
xs.sort_by(f32::total_cmp);
|
||||
@@ -154,6 +194,9 @@ fn rounded_rect(x: f32, y: f32, w: f32, h: f32, r: f32) -> Vec<[f32; 2]> {
|
||||
|
||||
/// Thick polyline → one quad per segment (each a separate path so overlaps don't cancel).
|
||||
fn stroke_paths(pts: &[[f32; 2]], width: f32, dash: bool, closed: bool) -> Vec<Path> {
|
||||
if pts.len() > MAX_STROKE_POINTS {
|
||||
return Vec::new();
|
||||
}
|
||||
let width = width.clamp(1.0, 50.0);
|
||||
let mut segs: Vec<([f32; 2], [f32; 2])> = pts.windows(2).map(|w| (w[0], w[1])).collect();
|
||||
if closed && let (Some(a), Some(b)) = (pts.last(), pts.first()) {
|
||||
@@ -212,7 +255,7 @@ pub fn rasterize(prims: &[Prim], w: u32, h: u32, background: Rgba) -> Vec<u8> {
|
||||
for _ in 0..w * h {
|
||||
buf.extend_from_slice(&background);
|
||||
}
|
||||
let mut cv = Canvas { w, h, buf, acc: Vec::new() };
|
||||
let mut cv = Canvas { w, h, buf, acc: Vec::new(), work: MAX_SCAN_WORK };
|
||||
for p in prims {
|
||||
match p {
|
||||
Prim::Rect { x, y, w, h, fill, stroke, radius } => {
|
||||
@@ -274,6 +317,40 @@ pub fn rasterize(prims: &[Prim], w: u32, h: u32, background: Rgba) -> Vec<u8> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Security: each row tests only the edges crossing it. A polygon of 100,000 tiny edges over
|
||||
/// 4,000 rows tested every edge at every sub-sample of every row (1.6 billion tests).
|
||||
#[test]
|
||||
fn many_short_edges_scan_only_their_rows() {
|
||||
let mut pts: Vec<[f32; 2]> = (0..100_000).map(|i| [if i % 2 == 0 { 0.0 } else { 0.5 }, i as f32 * 0.04]).collect();
|
||||
pts.push([2.0, 4_000.0]);
|
||||
pts.push([2.0, 0.0]);
|
||||
let img = rasterize(&[Prim::Polygon { pts, fill: [255, 0, 0, 255], stroke: None }], 2, 4_000, [0, 0, 0, 0]);
|
||||
// The right-hand pixel of every row is inside the polygon.
|
||||
assert_eq!(img.get(4..8), Some(&[255, 0, 0, 255][..]));
|
||||
assert_eq!(img.get(4 * (2 * 3_999 + 1)..4 * (2 * 3_999 + 2)), Some(&[255, 0, 0, 255][..]));
|
||||
}
|
||||
|
||||
/// Security: scan conversion stops once the call's work allowance is spent.
|
||||
#[test]
|
||||
fn fills_stop_when_the_work_allowance_is_spent() {
|
||||
let square = vec![vec![[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0]]];
|
||||
let mut cv = Canvas { w: 4, h: 4, buf: vec![0; 64], acc: Vec::new(), work: 2 * SUB as u64 * 4 + 16 };
|
||||
cv.fill(&square, [255; 4]);
|
||||
assert!(cv.buf.iter().all(|&b| b == 255));
|
||||
cv.buf = vec![0; 64];
|
||||
cv.fill(&square, [255; 4]);
|
||||
assert!(cv.buf.iter().all(|&b| b == 0));
|
||||
}
|
||||
|
||||
/// Security: a polyline over the point cap builds no segment paths at all, solid or dashed.
|
||||
#[test]
|
||||
fn oversized_polylines_build_no_segment_paths() {
|
||||
let line = |n: usize| -> Vec<[f32; 2]> { (0..n).map(|i| [i as f32, (i % 2) as f32]).collect() };
|
||||
assert!(stroke_paths(&line(MAX_STROKE_POINTS + 1), 1.0, false, false).is_empty());
|
||||
assert!(stroke_paths(&line(MAX_STROKE_POINTS + 1), 1.0, true, false).is_empty());
|
||||
assert_eq!(stroke_paths(&line(MAX_STROKE_POINTS), 1.0, false, false).len(), MAX_STROKE_POINTS - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashed_geometry_stays_within_the_dash_budget() {
|
||||
// Forty very long dashed lines, each allowed 10_000 dashes per segment: before the
|
||||
|
||||
+93
-12
@@ -1,11 +1,12 @@
|
||||
//! Chart layout: title, legend, axes and every chart kind, drawn into [`Prim`]s.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::f32::consts::{PI, TAU};
|
||||
|
||||
use gridcraft_model::{Chart, ChartKind, LineDash, QuartileMethod, Theme, TrendlineKind};
|
||||
|
||||
use crate::axis::{Fmt, Scale, clean, fixed_scale, nice_scale};
|
||||
use crate::resolve::{MAX_POINTS, MAX_SERIES, MAX_TRENDLINES, series_color};
|
||||
use crate::axis::{Fmt, Scale, clean, nice_scale};
|
||||
use crate::resolve::{MAX_POINTS, MAX_SERIES, MAX_TRENDLINES, points_per_series, series_color};
|
||||
use crate::trend::{auto_name, fit_trendline, moving_average};
|
||||
use crate::{ChartData, HAlign, Measure, Prim, Rgba, SeriesData, VAlign};
|
||||
|
||||
@@ -44,6 +45,8 @@ const MAX_LINE_PTS: usize = 2000;
|
||||
const MAX_MARKERS: usize = 5000;
|
||||
/// Data labels are skipped when a series has more points than this.
|
||||
const MAX_LABELS: usize = 200;
|
||||
/// Columns and bars narrower than this (in pixels) are merged per pixel column into one rectangle.
|
||||
const MIN_BAR_PX: f32 = 0.25;
|
||||
/// Most wedges a doughnut draws for the whole chart. The per-series and per-category limits do not
|
||||
/// bound their product: 255 series of 20,000 categories would be millions of wedges. Every drawable
|
||||
/// ring shares this budget, so the innermost ring keeps its largest slices and the outer rings are
|
||||
@@ -55,25 +58,53 @@ struct Ctx<'a> {
|
||||
m: &'a dyn Measure,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "render_perf_tests.rs"]
|
||||
mod perf_tests;
|
||||
|
||||
impl Ctx<'_> {
|
||||
fn tw(&self, s: &str, size: f32) -> f32 {
|
||||
let w = self.m.text_width(s, size, false);
|
||||
if w.is_finite() { w.max(0.0) } else { 0.0 }
|
||||
}
|
||||
/// `s` shortened with an ellipsis to fit `maxw`; empty when not even one character fits.
|
||||
///
|
||||
/// Binary search over the number of kept characters (at most 400 are tried), about nine
|
||||
/// `Measure` calls instead of one per character. It assumes the width of `prefix + "…"` does not
|
||||
/// shrink as the prefix grows, which holds for per-character advance sums (`ApproxMeasure`, the
|
||||
/// PDF metrics) and for the UI's text layout up to kerning of a few hundredths of a point; with
|
||||
/// such a non-monotone measure the result may differ from the old linear scan by a character.
|
||||
/// A label with a line break is not monotone (the ellipsis moves to a new line and the widest
|
||||
/// line can get shorter), so it takes the linear scan, longest prefix first.
|
||||
fn fit(&self, s: &str, size: f32, maxw: f32) -> String {
|
||||
if self.tw(s, size) <= maxw {
|
||||
return s.to_string();
|
||||
}
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let mut n = chars.len().min(400);
|
||||
while n > 0 {
|
||||
n -= 1;
|
||||
let t: String = chars.iter().take(n).collect::<String>() + "…";
|
||||
if self.tw(&t, size) <= maxw {
|
||||
return if n == 0 { String::new() } else { t };
|
||||
// Byte offset where each of the first 400 characters starts; a prefix of `k` characters
|
||||
// (k < number of characters, so the offset exists) ends at `starts[k]`.
|
||||
let starts: Vec<usize> = s.char_indices().map(|(i, _)| i).take(400).collect();
|
||||
let top = starts.len().saturating_sub(1);
|
||||
let fits = |k: usize| -> bool {
|
||||
let end = starts.get(k).copied().unwrap_or(s.len());
|
||||
let mut t = String::with_capacity(end + 3);
|
||||
t.push_str(s.get(..end).unwrap_or(""));
|
||||
t.push('…');
|
||||
self.tw(&t, size) <= maxw
|
||||
};
|
||||
if s.contains('\n') {
|
||||
let k = (1..=top).rev().find(|&k| fits(k));
|
||||
return k.map_or_else(String::new, |k| format!("{}…", s.get(..starts.get(k).copied().unwrap_or(s.len())).unwrap_or("")));
|
||||
}
|
||||
let (mut lo, mut hi) = (0usize, top);
|
||||
while lo < hi {
|
||||
let mid = lo + (hi - lo).div_ceil(2);
|
||||
if fits(mid) {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid - 1;
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
if lo == 0 { String::new() } else { format!("{}…", s.get(..starts.get(lo).copied().unwrap_or(s.len())).unwrap_or("")) }
|
||||
}
|
||||
fn text(&mut self, x: f32, y: f32, s: &str, size: f32, color: Rgba, (align, valign): (HAlign, VAlign)) {
|
||||
if s.is_empty() {
|
||||
@@ -101,7 +132,7 @@ fn sanitize_vals(v: &[Option<f64>]) -> Vec<Option<f64>> {
|
||||
v.iter().take(MAX_POINTS).map(|x| x.filter(|n| n.is_finite()).map(clean)).collect()
|
||||
}
|
||||
|
||||
fn sanitize(d: &ChartData) -> ChartData {
|
||||
pub(crate) fn sanitize(d: &ChartData) -> ChartData {
|
||||
ChartData {
|
||||
palette: d.palette.iter().take(MAX_POINTS).copied().collect(),
|
||||
categories: d.categories.iter().take(MAX_POINTS).cloned().collect(),
|
||||
@@ -126,12 +157,62 @@ fn sanitize(d: &ChartData) -> ChartData {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `v` survives `sanitize_vals` unchanged.
|
||||
fn is_clean(v: &Option<f64>) -> bool {
|
||||
v.is_none_or(|n| n.is_finite() && clean(n) == n)
|
||||
}
|
||||
|
||||
/// Whether [`sanitize`] and [`apply_budget`] would change `d`.
|
||||
fn needs_sanitizing(d: &ChartData) -> bool {
|
||||
let cap = points_per_series(d.series.len());
|
||||
let long = |n: usize| n > cap;
|
||||
let mut trendlines = 0usize;
|
||||
d.palette.len() > MAX_POINTS
|
||||
|| long(d.categories.len())
|
||||
|| d.category_levels.len() > 3
|
||||
|| d.category_levels.iter().any(|l| l.len() > MAX_POINTS)
|
||||
|| d.series.len() > MAX_SERIES
|
||||
|| d.series.iter().any(|s| {
|
||||
trendlines += s.trendlines.len();
|
||||
long(s.values.len())
|
||||
|| !s.values.iter().all(is_clean)
|
||||
|| s.x.as_ref().is_some_and(|x| long(x.len()) || !x.iter().all(is_clean))
|
||||
|| s.sizes.as_ref().is_some_and(|x| long(x.len()) || !x.iter().all(is_clean))
|
||||
|| s.trendlines.len() > MAX_TRENDLINES
|
||||
})
|
||||
|| trendlines > MAX_TRENDLINES
|
||||
}
|
||||
|
||||
/// Enforces the chart's budget of points (`MAX_TOTAL_POINTS` = series x points: every series and
|
||||
/// the category list keep their first `MAX_TOTAL_POINTS / series` points) and of trendlines
|
||||
/// (`MAX_TRENDLINES` per chart, in series order).
|
||||
fn apply_budget(mut d: ChartData) -> ChartData {
|
||||
let cap = points_per_series(d.series.len());
|
||||
d.categories.truncate(cap);
|
||||
let mut trendlines = MAX_TRENDLINES;
|
||||
for s in &mut d.series {
|
||||
s.values.truncate(cap);
|
||||
for v in [&mut s.x, &mut s.sizes].into_iter().flatten() {
|
||||
v.truncate(cap);
|
||||
}
|
||||
s.trendlines.truncate(trendlines);
|
||||
trendlines = trendlines.saturating_sub(s.trendlines.len());
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// The data `render` draws: `d` itself when it is already clean and within budget (the usual
|
||||
/// case: no copy), else a cleaned, truncated copy.
|
||||
pub(crate) fn sanitized(d: &ChartData) -> Cow<'_, ChartData> {
|
||||
if needs_sanitizing(d) { Cow::Owned(apply_budget(sanitize(d))) } else { Cow::Borrowed(d) }
|
||||
}
|
||||
|
||||
/// Lays out and draws a chart into a `w`×`h` box at the origin. Empty for a degenerate box.
|
||||
pub fn render(chart: &Chart, data: &ChartData, w: f32, h: f32, m: &dyn Measure) -> Vec<Prim> {
|
||||
if !(w.is_finite() && h.is_finite()) || w < 1.0 || h < 1.0 || w > 1e7 || h > 1e7 {
|
||||
return vec![];
|
||||
}
|
||||
let data = sanitize(data);
|
||||
let data = sanitized(data);
|
||||
let mut ctx = Ctx { out: Vec::new(), m };
|
||||
ctx.out.push(Prim::Rect { x: 0.0, y: 0.0, w, h, fill: Some(WHITE), stroke: Some((GRID, 1.0)), radius: 0.0 });
|
||||
let pad = (w.min(h) * 0.04).clamp(2.0, 10.0);
|
||||
|
||||
@@ -3,8 +3,8 @@ use super::legend::{cat_label, plot_kind};
|
||||
use super::lines::{decimate_area, draw_markers, draw_runs};
|
||||
use super::trend::{draw_trend_labels, draw_trendlines, max_backward, max_forward, max_forward_slots, supports_trendline};
|
||||
use super::{
|
||||
Chart, ChartData, ChartKind, Ctx, Fmt, HAlign, LABEL_SIZE, MAX_CATS, MAX_LABELS, MAX_LINE_PTS, MAX_MARKERS, Prim, Scale, SeriesData, TEXT,
|
||||
VAlign, WHITE, clean, fixed_scale, nice_scale,
|
||||
Chart, ChartData, ChartKind, Ctx, Fmt, HAlign, LABEL_SIZE, MAX_CATS, MAX_LABELS, MAX_LINE_PTS, MAX_MARKERS, MIN_BAR_PX, Prim, Rgba, Scale,
|
||||
SeriesData, TEXT, VAlign, WHITE, clean, nice_scale,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- cartesian (column/bar/line/area/combo)
|
||||
@@ -51,6 +51,110 @@ fn is_line(f: Fam) -> bool {
|
||||
matches!(f, Fam::Line | Fam::LineStack)
|
||||
}
|
||||
|
||||
/// Bars that fall into one pixel column, merged: extent along the category axis (`a0..=a1`, the
|
||||
/// bars' start positions) and along the value axis (`lo..hi`).
|
||||
struct BarRun {
|
||||
bucket: f32,
|
||||
a0: f32,
|
||||
a1: f32,
|
||||
lo: f32,
|
||||
hi: f32,
|
||||
/// More than one bar was merged.
|
||||
merged: bool,
|
||||
}
|
||||
|
||||
impl BarRun {
|
||||
fn new(pos: f32, lo: f32, hi: f32) -> Self {
|
||||
BarRun { bucket: pos.floor(), a0: pos, a1: pos, lo, hi, merged: false }
|
||||
}
|
||||
/// Whether `lo..hi` touches the run's value span; a disjoint bar would be joined by a filled gap.
|
||||
fn touches(&self, lo: f32, hi: f32) -> bool {
|
||||
lo <= self.hi && hi >= self.lo
|
||||
}
|
||||
fn add(&mut self, pos: f32, lo: f32, hi: f32) {
|
||||
self.merged = true;
|
||||
self.a0 = self.a0.min(pos);
|
||||
self.a1 = self.a1.max(pos);
|
||||
self.lo = self.lo.min(lo);
|
||||
self.hi = self.hi.max(hi);
|
||||
}
|
||||
/// `band` is the part of the pixel column reserved for this series (`start`, `len`): in a
|
||||
/// clustered chart a merged run stays inside it, so the other series keep a visible strip.
|
||||
fn draw(&self, ctx: &mut Ctx, bw: f32, horizontal: bool, color: Rgba, band: Option<(f32, f32)>) {
|
||||
let (a0, along) = match band {
|
||||
Some((start, len)) if self.merged => (self.bucket + start, len),
|
||||
_ => (self.a0, self.a1 - self.a0 + bw),
|
||||
};
|
||||
if horizontal {
|
||||
ctx.rect(self.lo, a0, self.hi - self.lo, along, color, None);
|
||||
} else {
|
||||
ctx.rect(a0, self.lo, along, self.hi - self.lo, color, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether bars of width `bw` are merged per pixel column (never when they carry data labels).
|
||||
fn merge_hairlines(bw: f32, labels_on: bool) -> bool {
|
||||
bw < MIN_BAR_PX && !labels_on
|
||||
}
|
||||
|
||||
/// Draws the bars of one series: a rectangle each, or, for hairline bars, one merged [`BarRun`]
|
||||
/// per pixel column.
|
||||
struct BarSink {
|
||||
run: Option<BarRun>,
|
||||
bw: f32,
|
||||
horizontal: bool,
|
||||
color: Rgba,
|
||||
nslots: f32,
|
||||
k: f32,
|
||||
}
|
||||
|
||||
impl BarSink {
|
||||
fn new(bw: f32, horizontal: bool, color: Rgba, nslots: f32, k: f32) -> Self {
|
||||
BarSink { run: None, bw, horizontal, color, nslots, k }
|
||||
}
|
||||
/// Start of this series' slot along the category axis, for the category centered at `c`.
|
||||
fn pos(&self, c: f32) -> f32 {
|
||||
if self.horizontal { c + self.nslots * self.bw / 2.0 - (self.k + 1.0) * self.bw } else { c - self.nslots * self.bw / 2.0 + self.k * self.bw }
|
||||
}
|
||||
/// This series' strip of a pixel column (offset, length) when the columns hold several
|
||||
/// clustered series, in the direction the slots run.
|
||||
fn band(&self) -> Option<(f32, f32)> {
|
||||
(self.nslots > 1.0).then(|| {
|
||||
let idx = if self.horizontal { self.nslots - 1.0 - self.k } else { self.k };
|
||||
(idx / self.nslots, 1.0 / self.nslots)
|
||||
})
|
||||
}
|
||||
/// Draws the bar at category center `c` spanning `lo..lo + len` on the value axis, or, when
|
||||
/// `merge` is set, adds it to the current run.
|
||||
fn bar(&mut self, ctx: &mut Ctx, merge: bool, c: f32, lo: f32, len: f32) {
|
||||
let pos = self.pos(c);
|
||||
let band = self.band();
|
||||
if !merge {
|
||||
if self.horizontal {
|
||||
ctx.rect(lo, pos, len, self.bw, self.color, None);
|
||||
} else {
|
||||
ctx.rect(pos, lo, self.bw, len, self.color, None);
|
||||
}
|
||||
return;
|
||||
}
|
||||
match &mut self.run {
|
||||
Some(m) if m.bucket == pos.floor() && m.touches(lo, lo + len) => m.add(pos, lo, lo + len),
|
||||
run => {
|
||||
if let Some(m) = run.replace(BarRun::new(pos, lo, lo + len)) {
|
||||
m.draw(ctx, self.bw, self.horizontal, self.color, band);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn finish(self, ctx: &mut Ctx) {
|
||||
let band = self.band();
|
||||
if let Some(m) = self.run {
|
||||
m.draw(ctx, self.bw, self.horizontal, self.color, band);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn series_spans(data: &ChartData, fams: &[Fam], sec: &[bool], ncat: usize) -> Vec<Vec<Option<(f64, f64)>>> {
|
||||
// Totals for 100% stacking, per axis and family (100% columns and 100% areas stack apart).
|
||||
let mut totals: Vec<((bool, Fam), Vec<f64>)> = Vec::new();
|
||||
@@ -141,11 +245,12 @@ fn axis_scales(data: &ChartData, fams: &[Fam], sec: &[bool], spans: &[Vec<Option
|
||||
let all100 = members.iter().all(|i| fams.get(*i).is_some_and(|f| is_100(*f)));
|
||||
let range = value_range(members.iter().flat_map(|i| {
|
||||
let line = fams.get(*i).is_some_and(|f| is_line(*f));
|
||||
spans.get(*i).into_iter().flatten().flatten().flat_map(move |(b, t)| if line { vec![*t] } else { vec![*b, *t] })
|
||||
// Lines contribute only their top value, bars both ends (no per-point `Vec`).
|
||||
spans.get(*i).into_iter().flatten().flatten().flat_map(move |(b, t)| [*b, *t].into_iter().skip(usize::from(line)))
|
||||
}));
|
||||
let (lo, hi) = range.unwrap_or((0.0, 0.0));
|
||||
let (scale, fmt) = if all100 {
|
||||
(fixed_scale(if lo < -1e-9 { -1.0 } else { 0.0 }, if hi > 1e-9 || lo >= -1e-9 { 1.0 } else { 0.0 }, 0.2), Fmt::new("0%"))
|
||||
(Scale { min: if lo < -1e-9 { -1.0 } else { 0.0 }, max: if hi > 1e-9 || lo >= -1e-9 { 1.0 } else { 0.0 }, step: 0.2 }, Fmt::new("0%"))
|
||||
} else {
|
||||
let code = members.first().and_then(|i| data.series.get(*i)).map(|s| s.number_format.as_str()).unwrap_or("General");
|
||||
(nice_scale(lo, hi, force_zero), Fmt::new(code))
|
||||
@@ -187,7 +292,7 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
|
||||
if ncat == 0 {
|
||||
return;
|
||||
}
|
||||
let kinds: Vec<ChartKind> = data.series.iter().map(|s| if horizontal { chart.kind } else { plot_kind(chart, s) }).collect();
|
||||
let kinds: Vec<ChartKind> = data.series.iter().enumerate().map(|(i, s)| if horizontal { chart.kind } else { plot_kind(chart, s, i) }).collect();
|
||||
let fams: Vec<Fam> = kinds.iter().map(|k| family(*k)).collect();
|
||||
let any_primary = data.series.iter().any(|s| !s.secondary);
|
||||
let sec: Vec<bool> = data.series.iter().map(|s| s.secondary && any_primary && !horizontal).collect();
|
||||
@@ -278,19 +383,16 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
|
||||
}
|
||||
1 => {
|
||||
let k = slot_of.get(i).copied().unwrap_or(0) as f32;
|
||||
// Hairline bars are merged per pixel column (their envelope is what shows).
|
||||
let cull = merge_hairlines(bw, labels_on);
|
||||
let mut sink = BarSink::new(bw, horizontal, s.color, nslots, k);
|
||||
for (j, sp) in row.iter().enumerate() {
|
||||
let Some((b, t)) = sp else { continue };
|
||||
let (pb, pt) = (vpos(*b, &sc), vpos(*t, &sc));
|
||||
let c = center(j);
|
||||
let lo = pb.min(pt);
|
||||
let len = (pb - pt).abs();
|
||||
if horizontal {
|
||||
let y = c + nslots * bw / 2.0 - (k + 1.0) * bw;
|
||||
ctx.rect(lo, y, len, bw, s.color, None);
|
||||
} else {
|
||||
let x = c - nslots * bw / 2.0 + k * bw;
|
||||
ctx.rect(x, lo, bw, len, s.color, None);
|
||||
}
|
||||
sink.bar(ctx, cull, c, lo, len);
|
||||
if labels_on && let Some(v) = s.values.get(j).copied().flatten() {
|
||||
let t = fmt.format(v, None);
|
||||
let stacked = fam != Fam::Col;
|
||||
@@ -316,6 +418,7 @@ pub(super) fn cartesian(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx
|
||||
}
|
||||
}
|
||||
}
|
||||
sink.finish(ctx);
|
||||
}
|
||||
_ => {
|
||||
let pts: Vec<Option<[f32; 2]>> = row.iter().enumerate().map(|(j, sp)| sp.map(|(_, t)| [center(j), vpos(t, &sc)])).collect();
|
||||
|
||||
@@ -340,7 +340,7 @@ pub(super) fn doughnut(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx)
|
||||
|
||||
// ---------------------------------------------------------------- treemap
|
||||
|
||||
/// Worst aspect ratio of a row with the given sum, smallest and largest area.
|
||||
/// Worst aspect ratio of a row with the given area sum, smallest and largest item, laid along `side`.
|
||||
fn worst(s: f64, mn: f64, mx: f64, side: f64) -> f64 {
|
||||
if s <= 0.0 || side <= 0.0 {
|
||||
return f64::INFINITY;
|
||||
@@ -350,8 +350,9 @@ fn worst(s: f64, mn: f64, mx: f64, side: f64) -> f64 {
|
||||
(w2 * mx / s2).max(s2 / (w2 * mn))
|
||||
}
|
||||
|
||||
/// Squarified treemap layout of (index, area) items, sorted descending, into `r`.
|
||||
fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> {
|
||||
/// Squarified treemap layout of (index, value) items, sorted descending, into `r`; each block
|
||||
/// comes back with its index and value.
|
||||
pub(super) fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, f64, Bx)> {
|
||||
let total: f64 = items.iter().map(|i| i.1).sum();
|
||||
let area = (r.w as f64) * (r.h as f64);
|
||||
if !(total > 0.0 && area > 0.0) {
|
||||
@@ -360,11 +361,10 @@ fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> {
|
||||
let scale = area / total;
|
||||
let mut out = Vec::with_capacity(items.len());
|
||||
let mut rem = r;
|
||||
let mut row: Vec<(usize, f64)> = Vec::new();
|
||||
// The row's sum, smallest and largest area, kept as items join so each step is O(1).
|
||||
let mut row: Vec<(usize, f64, f64)> = Vec::new();
|
||||
let (mut sum, mut mn, mut mx) = (0.0f64, f64::INFINITY, 0.0f64);
|
||||
let mut i = 0;
|
||||
let layout = |row: &[(usize, f64)], rem: &mut Bx, out: &mut Vec<(usize, Bx)>| {
|
||||
let layout = |row: &[(usize, f64, f64)], rem: &mut Bx, out: &mut Vec<(usize, f64, Bx)>| {
|
||||
let s: f64 = row.iter().map(|x| x.1).sum();
|
||||
if s <= 0.0 {
|
||||
return;
|
||||
@@ -372,9 +372,9 @@ fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> {
|
||||
if rem.w >= rem.h {
|
||||
let cw = (s / rem.h.max(1e-6) as f64) as f32;
|
||||
let mut y = rem.y;
|
||||
for (j, a) in row {
|
||||
for (j, a, v) in row {
|
||||
let h = (*a / cw.max(1e-6) as f64) as f32;
|
||||
out.push((*j, Bx { x: rem.x, y, w: cw, h }));
|
||||
out.push((*j, *v, Bx { x: rem.x, y, w: cw, h }));
|
||||
y += h;
|
||||
}
|
||||
rem.x += cw;
|
||||
@@ -382,9 +382,9 @@ fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> {
|
||||
} else {
|
||||
let rh = (s / rem.w.max(1e-6) as f64) as f32;
|
||||
let mut x = rem.x;
|
||||
for (j, a) in row {
|
||||
for (j, a, v) in row {
|
||||
let w = (*a / rh.max(1e-6) as f64) as f32;
|
||||
out.push((*j, Bx { x, y: rem.y, w, h: rh }));
|
||||
out.push((*j, *v, Bx { x, y: rem.y, w, h: rh }));
|
||||
x += w;
|
||||
}
|
||||
rem.y += rh;
|
||||
@@ -394,9 +394,12 @@ fn squarify(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> {
|
||||
while let Some(&(j, v)) = items.get(i) {
|
||||
let a = v * scale;
|
||||
let side = rem.w.min(rem.h) as f64;
|
||||
if row.is_empty() || worst(sum + a, mn.min(a), mx.max(a), side) <= worst(sum, mn, mx, side) {
|
||||
row.push((j, a));
|
||||
(sum, mn, mx) = (sum + a, mn.min(a), mx.max(a));
|
||||
// Sum, smallest and largest item of the open row, kept incrementally (same fold order as
|
||||
// summing the row afresh, so the numbers are bit-identical).
|
||||
let with = (sum + a, mn.min(a), mx.max(a));
|
||||
if row.is_empty() || worst(with.0, with.1, with.2, side) <= worst(sum, mn, mx, side) {
|
||||
row.push((j, a, v));
|
||||
(sum, mn, mx) = with;
|
||||
i += 1;
|
||||
} else {
|
||||
layout(&row, &mut rem, &mut out);
|
||||
@@ -416,15 +419,13 @@ pub(super) fn treemap(ctx: &mut Ctx, chart: &Chart, data: &ChartData, area: Bx)
|
||||
s.values.iter().take(MAX_MARKERS).enumerate().filter_map(|(j, v)| v.filter(|v| *v > 0.0).map(|v| (j, v))).collect();
|
||||
items.sort_by(|a, b| b.1.total_cmp(&a.1));
|
||||
let f = Fmt::new(&s.number_format);
|
||||
let value_of: std::collections::HashMap<usize, f64> = items.iter().copied().collect();
|
||||
for (j, b) in squarify(&items, area) {
|
||||
for (j, v, b) in squarify(&items, area) {
|
||||
let fill = point_color(data, j);
|
||||
ctx.rect(b.x, b.y, b.w, b.h, fill, Some((WHITE, 1.5)));
|
||||
if b.w > 24.0 && b.h > LABEL_SIZE * 1.6 {
|
||||
let name = ctx.fit(&cat_label(data, j), LABEL_SIZE, b.w - 8.0);
|
||||
ctx.text(b.x + 4.0, b.y + 4.0, &name, LABEL_SIZE, on(fill), (HAlign::Left, VAlign::Top));
|
||||
if chart.data_labels && b.h > LABEL_SIZE * 3.2 {
|
||||
let v = value_of.get(&j).copied().unwrap_or(0.0);
|
||||
let t = ctx.fit(&f.format(v, None), LABEL_SIZE, b.w - 8.0);
|
||||
ctx.text(b.x + 4.0, b.y + 4.0 + LABEL_SIZE * 1.4, &t, LABEL_SIZE, on(fill), (HAlign::Left, VAlign::Top));
|
||||
}
|
||||
|
||||
@@ -15,9 +15,10 @@ pub enum Swatch {
|
||||
Marker,
|
||||
}
|
||||
|
||||
/// The kind a series is drawn as (per-series kinds only apply to combo charts).
|
||||
pub(super) fn plot_kind(chart: &Chart, s: &SeriesData) -> ChartKind {
|
||||
if chart.kind == ChartKind::Combo { if s.kind == ChartKind::Combo { ChartKind::Line } else { s.kind } } else { chart.kind }
|
||||
/// The kind series `i` is drawn as (per-series kinds only apply to combo charts); the same rule
|
||||
/// as `series_plot_kind`, applied to the already resolved series kind.
|
||||
pub(super) fn plot_kind(chart: &Chart, s: &SeriesData, i: usize) -> ChartKind {
|
||||
crate::resolve::plot_kind(chart.kind, Some(s.kind), i)
|
||||
}
|
||||
|
||||
/// Colour of point `j` for charts that colour by point (pie, doughnut, treemap, sunburst).
|
||||
@@ -60,8 +61,9 @@ pub fn legend_entries(chart: &Chart, data: &ChartData) -> Vec<(String, Rgba, Swa
|
||||
let series = data.series.iter().take(MAX_SERIES);
|
||||
let mut out: Vec<(String, Rgba, Swatch)> = series
|
||||
.clone()
|
||||
.map(|s| {
|
||||
let sw = match plot_kind(chart, s) {
|
||||
.enumerate()
|
||||
.map(|(i, s)| {
|
||||
let sw = match plot_kind(chart, s, i) {
|
||||
ChartKind::Line | ChartKind::LineStacked | ChartKind::Radar | ChartKind::ScatterLines => Swatch::Line,
|
||||
ChartKind::LineMarkers => Swatch::LineMarker,
|
||||
ChartKind::Scatter | ChartKind::Bubble => Swatch::Marker,
|
||||
@@ -71,7 +73,7 @@ pub fn legend_entries(chart: &Chart, data: &ChartData) -> Vec<(String, Rgba, Swa
|
||||
})
|
||||
.collect();
|
||||
// Trendlines follow the series, named "Linear (Sales)" unless given a name.
|
||||
for s in series.filter(|s| supports_trendline(plot_kind(chart, s))) {
|
||||
for (_, s) in series.enumerate().filter(|(i, s)| supports_trendline(plot_kind(chart, s, *i))) {
|
||||
for t in s.trendlines.iter().take(MAX_TRENDLINES) {
|
||||
out.push((t.spec.name.clone().unwrap_or_else(|| auto_name(t.spec.kind, &s.name)), t.color, Swatch::Line));
|
||||
}
|
||||
@@ -83,7 +85,7 @@ pub fn legend_entries(chart: &Chart, data: &ChartData) -> Vec<(String, Rgba, Swa
|
||||
|
||||
// ---------------------------------------------------------------- legend
|
||||
|
||||
fn swatch_w(sw: Swatch, s: f32) -> f32 {
|
||||
pub(super) fn swatch_w(sw: Swatch, s: f32) -> f32 {
|
||||
if sw == Swatch::Box { s } else { s * 2.0 }
|
||||
}
|
||||
|
||||
@@ -147,7 +149,16 @@ pub(super) fn draw_legend(ctx: &mut Ctx, entries: &[(String, Rgba, Swatch)], pos
|
||||
if pos == LegendPos::Bottom { Bx { h: area.h - used, ..area } } else { Bx { y: area.y + used, h: area.h - used, ..area } }
|
||||
}
|
||||
LegendPos::Left | LegendPos::Right => {
|
||||
let colw = entries.iter().map(|e| item_w(ctx, e)).fold(0.0f32, f32::max).min(area.w * 0.35);
|
||||
// Widest entry, capped at 35% of the area: once the cap is reached the rest cannot change it.
|
||||
let cap = area.w * 0.35;
|
||||
let mut colw = 0.0f32;
|
||||
for e in entries {
|
||||
if colw >= cap {
|
||||
break;
|
||||
}
|
||||
colw = colw.max(item_w(ctx, e));
|
||||
}
|
||||
let colw = colw.min(cap);
|
||||
let max_rows = ((area.h / row_h).floor() as usize).max(1);
|
||||
let n = entries.len().min(max_rows);
|
||||
let lh = n as f32 * row_h;
|
||||
|
||||
@@ -136,7 +136,13 @@ pub(super) fn decimate_area(top: Vec<[f32; 2]>, base: Vec<[f32; 2]>, max: usize)
|
||||
|
||||
/// Draws a polyline split at gaps (`None`), decimated when long. With `smooth` (the plot box),
|
||||
/// each run is interpolated with a Catmull-Rom spline so the line curves through the data points.
|
||||
///
|
||||
/// The points of the smoothed runs (data points included) are budgeted across all the runs of the line, not per run:
|
||||
/// short runs between gaps escape decimation, and 100 sub-segments each would turn a
|
||||
/// 100,000-point series of three-point runs into millions of points. Past the budget the
|
||||
/// remaining runs are drawn through their data points only.
|
||||
pub(super) fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, width: f32, smooth: Option<Bx>) {
|
||||
let mut budget = MAX_LINE_PTS * 2;
|
||||
let mut run: Vec<[f32; 2]> = Vec::new();
|
||||
for p in pts.iter().chain(std::iter::once(&None)) {
|
||||
match p {
|
||||
@@ -145,7 +151,11 @@ pub(super) fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, wi
|
||||
if run.len() >= 2 {
|
||||
let r = decimate(std::mem::take(&mut run), MAX_LINE_PTS);
|
||||
let r = match smooth {
|
||||
Some(plot) => smooth_run(r, plot),
|
||||
Some(plot) => {
|
||||
let r = smooth_run(r, plot, budget);
|
||||
budget = budget.saturating_sub(r.len());
|
||||
r
|
||||
}
|
||||
None => r,
|
||||
};
|
||||
ctx.line(r, color, width);
|
||||
@@ -158,15 +168,15 @@ pub(super) fn draw_runs(ctx: &mut Ctx, pts: &[Option<[f32; 2]>], color: Rgba, wi
|
||||
}
|
||||
|
||||
/// Catmull-Rom spline through `pts`: every segment gets the same number of sub-segments (up to
|
||||
/// 100), chosen so the whole curve stays within `MAX_LINE_PTS * 2` points. Endpoints are
|
||||
/// 100), chosen so the whole curve stays within `cap` points (one per data point when `cap` is
|
||||
/// smaller than that). Endpoints are
|
||||
/// duplicated so the curve passes through the first and last points, and the curve is clamped
|
||||
/// to the `plot` box (a spline overshoots its data near sharp turns).
|
||||
fn smooth_run(pts: Vec<[f32; 2]>, plot: Bx) -> Vec<[f32; 2]> {
|
||||
fn smooth_run(pts: Vec<[f32; 2]>, plot: Bx, cap: usize) -> Vec<[f32; 2]> {
|
||||
let n = pts.len();
|
||||
if n < 3 {
|
||||
return pts;
|
||||
}
|
||||
let cap = MAX_LINE_PTS * 2;
|
||||
let steps = (cap / n).clamp(1, 100);
|
||||
let mut out: Vec<[f32; 2]> = Vec::with_capacity(n.saturating_mul(steps).min(cap) + 1);
|
||||
let at = |i: isize| -> [f32; 2] {
|
||||
@@ -219,7 +229,7 @@ mod smooth_tests {
|
||||
fn long_runs_are_smoothed_uniformly_within_the_cap() {
|
||||
let plot = Bx { x: 0.0, y: 0.0, w: 3000.0, h: 100.0 };
|
||||
let pts: Vec<[f32; 2]> = (0..1500).map(|i| [i as f32 * 2.0, if i % 2 == 0 { 20.0 } else { 80.0 }]).collect();
|
||||
let out = smooth_run(pts, plot);
|
||||
let out = smooth_run(pts, plot, MAX_LINE_PTS * 2);
|
||||
assert!(out.len() <= MAX_LINE_PTS * 2 + 1, "{}", out.len());
|
||||
// Two sub-segments per segment all the way to the end, not 100 each and then a straight tail.
|
||||
assert_eq!(out.len(), 1499 * 2 + 1);
|
||||
@@ -231,11 +241,29 @@ mod smooth_tests {
|
||||
let plot = Bx { x: 0.0, y: 10.0, w: 300.0, h: 100.0 };
|
||||
// A step right at the plot edges: Catmull-Rom overshoots past both.
|
||||
let pts = vec![[0.0, 110.0], [100.0, 110.0], [110.0, 10.0], [200.0, 10.0], [300.0, 10.0]];
|
||||
let out = smooth_run(pts, plot);
|
||||
let out = smooth_run(pts, plot, MAX_LINE_PTS * 2);
|
||||
assert!(out.len() > 5);
|
||||
assert!(out.iter().all(|p| (0.0..=300.0).contains(&p[0]) && (10.0..=110.0).contains(&p[1])));
|
||||
}
|
||||
|
||||
/// Security: smoothing budgets its added points across every run of a line. A series of
|
||||
/// three-point runs between gaps used to get 100 sub-segments per run, 67 times its data.
|
||||
#[test]
|
||||
fn gap_separated_runs_share_one_smoothing_budget() {
|
||||
let plot = Bx { x: 0.0, y: 0.0, w: 1e6, h: 100.0 };
|
||||
let pts: Vec<Option<[f32; 2]>> =
|
||||
(0..100_000).map(|i| if i % 4 == 3 { None } else { Some([i as f32, if i % 2 == 0 { 20.0 } else { 80.0 }]) }).collect();
|
||||
let m = crate::ApproxMeasure;
|
||||
let mut ctx = Ctx { out: Vec::new(), m: &m };
|
||||
draw_runs(&mut ctx, &pts, WHITE, 1.0, Some(plot));
|
||||
let total: usize = ctx.out.iter().map(|p| if let Prim::Line { pts, .. } = p { pts.len() } else { 0 }).sum();
|
||||
// 75,000 data points, plus at most the budget added by interpolation.
|
||||
assert!(total <= 75_000 + MAX_LINE_PTS * 2, "{total}");
|
||||
assert_eq!(ctx.out.len(), 25_000);
|
||||
// The first runs are still smoothed.
|
||||
assert!(matches!(ctx.out.first(), Some(Prim::Line { pts, .. }) if pts.len() > 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimate_area_preserves_paired_vertical_strips() {
|
||||
let top: Vec<[f32; 2]> = (0..1000).map(|i| [i as f32 * 0.25, ((i * 37) % 100) as f32]).collect();
|
||||
|
||||
@@ -70,7 +70,7 @@ pub(super) fn draw(ctx: &mut Ctx, chart: &Chart, data: &ChartData, plot: Bx, lay
|
||||
let y = top + (layout.headers as f32 + i as f32 + 0.5) * layout.row_height;
|
||||
let name_x = if settings.show_keys { x0 + 25.0 } else { x0 + 4.0 };
|
||||
if settings.show_keys {
|
||||
let swatch = match plot_kind(chart, series) {
|
||||
let swatch = match plot_kind(chart, series, i) {
|
||||
ChartKind::Line | ChartKind::LineStacked => Swatch::Line,
|
||||
ChartKind::LineMarkers => Swatch::LineMarker,
|
||||
_ => Swatch::Box,
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
//! Equality and counter tests for the label-fitting, legend-measuring and treemap rewrites.
|
||||
//! The old implementations live here as references.
|
||||
|
||||
use std::cell::Cell;
|
||||
|
||||
use super::kinds::squarify;
|
||||
use super::legend::swatch_w;
|
||||
use super::*;
|
||||
use crate::{ApproxMeasure, Measure};
|
||||
use gridcraft_model::LegendPos;
|
||||
|
||||
/// The pre-rewrite `Ctx::fit`: one `Measure` call per candidate length, longest first.
|
||||
fn fit_reference(ctx: &Ctx, s: &str, size: f32, maxw: f32) -> String {
|
||||
if ctx.tw(s, size) <= maxw {
|
||||
return s.to_string();
|
||||
}
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let mut n = chars.len().min(400);
|
||||
while n > 0 {
|
||||
n -= 1;
|
||||
let t: String = chars.iter().take(n).collect::<String>() + "…";
|
||||
if ctx.tw(&t, size) <= maxw {
|
||||
return if n == 0 { String::new() } else { t };
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// Counts calls; advances differ per character (wide CJK/emoji, zero-width combining marks) and
|
||||
/// "AV"-style pairs are kerned tighter, but a character never makes the text narrower.
|
||||
struct Varied {
|
||||
calls: Cell<usize>,
|
||||
}
|
||||
|
||||
impl Measure for Varied {
|
||||
fn text_width(&self, text: &str, size: f32, bold: bool) -> f32 {
|
||||
self.calls.set(self.calls.get() + 1);
|
||||
let mut w = 0.0f32;
|
||||
let mut prev = ' ';
|
||||
for c in text.chars() {
|
||||
let adv = match c as u32 {
|
||||
0x300..=0x36f => 0.0,
|
||||
0x1f300..=0x1faff | 0x4e00..=0x9fff => 1.0,
|
||||
u => 0.3 + (u % 7) as f32 * 0.07,
|
||||
};
|
||||
let kern = if prev == 'A' && c == 'V' { 0.1 } else { 0.0 };
|
||||
w += (adv - kern) * size;
|
||||
prev = c;
|
||||
}
|
||||
if bold { w * 1.1 } else { w }
|
||||
}
|
||||
}
|
||||
|
||||
struct Counting<'a>(&'a dyn Measure, Cell<usize>);
|
||||
|
||||
impl Measure for Counting<'_> {
|
||||
fn text_width(&self, text: &str, size: f32, bold: bool) -> f32 {
|
||||
self.1.set(self.1.get() + 1);
|
||||
self.0.text_width(text, size, bold)
|
||||
}
|
||||
}
|
||||
|
||||
/// Like a text layout that breaks at `\n`: the width is that of the widest line. Adding a
|
||||
/// newline can make the text narrower, so the width of `prefix + "…"` is not monotone.
|
||||
struct WidestLine;
|
||||
|
||||
impl Measure for WidestLine {
|
||||
fn text_width(&self, text: &str, size: f32, _bold: bool) -> f32 {
|
||||
text.split('\n')
|
||||
.map(|line| {
|
||||
line.chars()
|
||||
.map(|c| {
|
||||
size * if c == 'i' {
|
||||
0.25
|
||||
} else if c == 'M' {
|
||||
0.9
|
||||
} else {
|
||||
0.5
|
||||
}
|
||||
})
|
||||
.sum::<f32>()
|
||||
})
|
||||
.fold(0.0, f32::max)
|
||||
}
|
||||
}
|
||||
|
||||
fn corpus() -> Vec<String> {
|
||||
let mut v: Vec<String> =
|
||||
["", "a", "ab", "Hello, world", "AVAVAV tight", "日本語のラベルです", "👨👩👧 family 🎉 party", "e\u{301}e\u{301}e\u{301} combining", "…", "……"]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
v.push("x".repeat(399));
|
||||
v.push("x".repeat(400));
|
||||
v.push("x".repeat(401));
|
||||
v.push("é🎉".repeat(500));
|
||||
v.push("long label ".repeat(100));
|
||||
for s in ["iiii\nMM", "iiii\n", "\niiii", "ab\ncd\nef", "iiiiiiii\niiiiMMMM\nMM", "line one\nline two is longer\nx"] {
|
||||
v.push(s.to_string());
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn run_fit(m: &dyn Measure) {
|
||||
let ctx = Ctx { out: vec![], m };
|
||||
for s in corpus() {
|
||||
for size in [9.0f32, 11.0, 40.0] {
|
||||
for maxw in [-5.0, 0.0, 0.4, 6.0, 25.0, 61.5, 120.0, 800.0, 1e6, f32::INFINITY, f32::NAN] {
|
||||
assert_eq!(ctx.fit(&s, size, maxw), fit_reference(&ctx, &s, size, maxw), "{s:?} size {size} maxw {maxw}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_matches_linear_reference() {
|
||||
run_fit(&ApproxMeasure);
|
||||
run_fit(&Varied { calls: Cell::new(0) });
|
||||
run_fit(&WidestLine);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_multiline_label_keeps_its_fitting_prefix() {
|
||||
// The whole label is 18 wide and "iii…" is 12.5, both too wide for 10.5, but "iiii\n…" is
|
||||
// 10 wide and fits. A binary search that stops at the failing midpoint would return nothing.
|
||||
let ctx = Ctx { out: vec![], m: &WidestLine };
|
||||
let s = "iiii\nMM";
|
||||
let size = 10.0;
|
||||
let maxw = 10.5;
|
||||
assert!(ctx.tw(s, size) > maxw);
|
||||
assert!(ctx.tw("iii…", size) > maxw && ctx.tw("iiii\n…", size) <= maxw);
|
||||
assert_eq!(ctx.fit(s, size, maxw), "iiii\n…");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_measures_logarithmically() {
|
||||
let long = "x".repeat(1000);
|
||||
let c = Counting(&ApproxMeasure, Cell::new(0));
|
||||
let ctx = Ctx { out: vec![], m: &c };
|
||||
let r = ctx.fit(&long, 11.0, 60.0);
|
||||
assert!(r.ends_with('…') && r.chars().count() > 3);
|
||||
// 1 whole-string check + ceil(log2(399)) + 1 probes; the old scan needed ~390 here.
|
||||
assert!(c.1.get() <= 12, "{} Measure calls", c.1.get());
|
||||
let c2 = Counting(&ApproxMeasure, Cell::new(0));
|
||||
let ctx2 = Ctx { out: vec![], m: &c2 };
|
||||
assert_eq!(ctx2.fit("short", 11.0, 500.0), "short");
|
||||
assert_eq!(c2.1.get(), 1);
|
||||
}
|
||||
|
||||
fn legend_entries(n: usize, label: &dyn Fn(usize) -> String) -> Vec<(String, Rgba, Swatch)> {
|
||||
(0..n).map(|i| (label(i), [10, 20, 30, 255], if i % 3 == 0 { Swatch::Line } else { Swatch::Box })).collect()
|
||||
}
|
||||
|
||||
/// Colour column width as the old code computed it, from the returned remaining area.
|
||||
fn old_colw(m: &dyn Measure, entries: &[(String, Rgba, Swatch)], area: Bx) -> f32 {
|
||||
let ctx = Ctx { out: vec![], m };
|
||||
let size = LABEL_SIZE;
|
||||
let s = size * 0.8;
|
||||
entries.iter().map(|e| swatch_w(e.2, s) + 4.0 + ctx.tw(&e.0, size)).fold(0.0f32, f32::max).min(area.w * 0.35)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn side_legend_matches_old_column_width_and_stops_measuring_at_the_cap() {
|
||||
let area = Bx { x: 0.0, y: 0.0, w: 400.0, h: 300.0 };
|
||||
let labels: [&dyn Fn(usize) -> String; 3] =
|
||||
[&|i| format!("entry {i}"), &|i| if i == 7 { "w".repeat(80) } else { format!("e{i}") }, &|i| "very long legend label ".repeat(1 + i % 3)];
|
||||
for pos in [LegendPos::Left, LegendPos::Right] {
|
||||
for (k, label) in labels.iter().enumerate() {
|
||||
for n in [0usize, 1, 5, 40] {
|
||||
let entries = legend_entries(n, label);
|
||||
let mut ctx = Ctx { out: vec![], m: &ApproxMeasure };
|
||||
let rest = draw_legend(&mut ctx, &entries, pos, area);
|
||||
let colw = old_colw(&ApproxMeasure, &entries, area);
|
||||
assert!((rest.w - (area.w - (colw + 8.0))).abs() < 1e-4, "{pos:?} labels {k} n {n}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// 20,000 entries whose first one already fills the 35% cap: the old code measured all of them
|
||||
// plus the drawn rows, now only the first entry and the drawn rows are measured.
|
||||
let entries = legend_entries(20_000, &|i| if i == 0 { "w".repeat(200) } else { format!("e{i}") });
|
||||
let c = Counting(&ApproxMeasure, Cell::new(0));
|
||||
let mut ctx = Ctx { out: vec![], m: &c };
|
||||
let _ = draw_legend(&mut ctx, &entries, LegendPos::Right, area);
|
||||
let drawn = ctx.out.iter().filter(|p| matches!(p, Prim::Text { .. })).count();
|
||||
assert!(c.1.get() <= drawn * 10 + 5, "{} Measure calls for {drawn} drawn entries", c.1.get());
|
||||
assert!(c.1.get() < 500);
|
||||
}
|
||||
|
||||
/// The pre-rewrite `worst`/`squarify` (a `Vec` per item, row recomputed from scratch).
|
||||
fn worst_reference(row: &[f64], side: f64) -> f64 {
|
||||
let s: f64 = row.iter().sum();
|
||||
if s <= 0.0 || side <= 0.0 {
|
||||
return f64::INFINITY;
|
||||
}
|
||||
let (mn, mx) = row.iter().fold((f64::INFINITY, 0.0f64), |(a, b), v| (a.min(*v), b.max(*v)));
|
||||
let s2 = s * s;
|
||||
let w2 = side * side;
|
||||
(w2 * mx / s2).max(s2 / (w2 * mn))
|
||||
}
|
||||
|
||||
fn squarify_reference(items: &[(usize, f64)], r: Bx) -> Vec<(usize, Bx)> {
|
||||
let total: f64 = items.iter().map(|i| i.1).sum();
|
||||
let area = (r.w as f64) * (r.h as f64);
|
||||
if !(total > 0.0 && area > 0.0) {
|
||||
return vec![];
|
||||
}
|
||||
let scale = area / total;
|
||||
let mut out = Vec::with_capacity(items.len());
|
||||
let mut rem = r;
|
||||
let mut row: Vec<(usize, f64)> = Vec::new();
|
||||
let mut i = 0;
|
||||
let layout = |row: &[(usize, f64)], rem: &mut Bx, out: &mut Vec<(usize, Bx)>| {
|
||||
let s: f64 = row.iter().map(|x| x.1).sum();
|
||||
if s <= 0.0 {
|
||||
return;
|
||||
}
|
||||
if rem.w >= rem.h {
|
||||
let cw = (s / rem.h.max(1e-6) as f64) as f32;
|
||||
let mut y = rem.y;
|
||||
for (j, a) in row {
|
||||
let h = (*a / cw.max(1e-6) as f64) as f32;
|
||||
out.push((*j, Bx { x: rem.x, y, w: cw, h }));
|
||||
y += h;
|
||||
}
|
||||
rem.x += cw;
|
||||
rem.w = (rem.w - cw).max(0.0);
|
||||
} else {
|
||||
let rh = (s / rem.w.max(1e-6) as f64) as f32;
|
||||
let mut x = rem.x;
|
||||
for (j, a) in row {
|
||||
let w = (*a / rh.max(1e-6) as f64) as f32;
|
||||
out.push((*j, Bx { x, y: rem.y, w, h: rh }));
|
||||
x += w;
|
||||
}
|
||||
rem.y += rh;
|
||||
rem.h = (rem.h - rh).max(0.0);
|
||||
}
|
||||
};
|
||||
while let Some(&(j, v)) = items.get(i) {
|
||||
let a = v * scale;
|
||||
let side = rem.w.min(rem.h) as f64;
|
||||
let cur: Vec<f64> = row.iter().map(|x| x.1).collect();
|
||||
let mut with = cur.clone();
|
||||
with.push(a);
|
||||
if row.is_empty() || worst_reference(&with, side) <= worst_reference(&cur, side) {
|
||||
row.push((j, a));
|
||||
i += 1;
|
||||
} else {
|
||||
layout(&row, &mut rem, &mut out);
|
||||
row.clear();
|
||||
}
|
||||
}
|
||||
if !row.is_empty() {
|
||||
layout(&row, &mut rem, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn squarify_matches_reference() {
|
||||
let mut seed = 0x2545_f491_4f6c_dd1du64;
|
||||
let mut rnd = move || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
(seed >> 11) as f64 / (1u64 << 53) as f64
|
||||
};
|
||||
let mut cases: Vec<Vec<f64>> = vec![
|
||||
vec![],
|
||||
vec![5.0],
|
||||
vec![6.0, 6.0, 4.0, 3.0, 2.0, 2.0, 1.0],
|
||||
vec![3.0, 0.0, 0.0, 1.0],
|
||||
vec![0.0, 0.0],
|
||||
vec![4.0, -1.0, 2.0],
|
||||
vec![4.0, f64::NAN, 2.0, 1.0],
|
||||
vec![f64::INFINITY, 1.0],
|
||||
vec![1e-300, 1e-310, 0.0],
|
||||
];
|
||||
for n in [2usize, 9, 40, 300] {
|
||||
let mut v: Vec<f64> = (0..n).map(|_| rnd() * 100.0).collect();
|
||||
v.sort_by(|a, b| b.total_cmp(a));
|
||||
cases.push(v);
|
||||
}
|
||||
for r in [Bx { x: 0.0, y: 0.0, w: 300.0, h: 200.0 }, Bx { x: 5.0, y: 7.0, w: 90.0, h: 400.0 }, Bx { x: 0.0, y: 0.0, w: 0.0, h: 50.0 }] {
|
||||
for vals in &cases {
|
||||
let items: Vec<(usize, f64)> = vals.iter().copied().enumerate().collect();
|
||||
let new: Vec<(usize, Bx)> = squarify(&items, r)
|
||||
.into_iter()
|
||||
.map(|(j, v, b)| {
|
||||
assert!(vals.get(j).is_some_and(|x| x.to_bits() == v.to_bits()), "value carried through");
|
||||
(j, b)
|
||||
})
|
||||
.collect();
|
||||
let old = squarify_reference(&items, r);
|
||||
assert_eq!(format!("{new:?}"), format!("{old:?}"), "{vals:?} in {r:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
+282
-38
@@ -1,7 +1,7 @@
|
||||
//! Pulls chart data (names, categories, values, formats, colours) out of a workbook.
|
||||
|
||||
use gridcraft_calc::evaluate;
|
||||
use gridcraft_core::{CellRef, RangeRef, Value, category_levels, number_to_text};
|
||||
use gridcraft_core::{CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, category_levels, number_to_text};
|
||||
use gridcraft_model::{Chart, ChartKind, Theme, Workbook, style::apply_tint};
|
||||
use gridcraft_numfmt::{NumberFormat, format_value};
|
||||
|
||||
@@ -11,8 +11,17 @@ use crate::{ChartData, Rgba, SeriesData, TrendlineData};
|
||||
pub(crate) const MAX_POINTS: usize = 100_000;
|
||||
/// Cap on series per chart.
|
||||
pub(crate) const MAX_SERIES: usize = 255;
|
||||
/// Cap on trendlines drawn per series.
|
||||
/// Cap on trendlines per chart (not per series: each one is a fit over a series' points).
|
||||
pub(crate) const MAX_TRENDLINES: usize = 16;
|
||||
/// Budget for series x points in one chart. When a chart would hold more, every series (and the
|
||||
/// category list) keeps its first `MAX_TOTAL_POINTS / series` points; no series is dropped.
|
||||
pub(crate) const MAX_TOTAL_POINTS: usize = 2_000_000;
|
||||
|
||||
/// Points kept per series (and categories) in a chart of `series` series: `MAX_POINTS`, or less
|
||||
/// when `series x MAX_POINTS` would pass `MAX_TOTAL_POINTS`.
|
||||
pub(crate) fn points_per_series(series: usize) -> usize {
|
||||
(MAX_TOTAL_POINTS / series.clamp(1, MAX_SERIES)).clamp(1, MAX_POINTS)
|
||||
}
|
||||
|
||||
/// 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];
|
||||
@@ -77,10 +86,51 @@ fn union_parts(f: &str) -> Option<Vec<&str>> {
|
||||
/// Evaluates a series formula to a flat list of values (row-major), capped. A union of
|
||||
/// references (`(A1:A3,A5)`, as PivotCharts use) is read part by part.
|
||||
fn eval_list(wb: &Workbook, sheet: usize, formula: &str) -> Vec<Value> {
|
||||
eval_list_in(wb, sheet, formula, 0)
|
||||
eval_list_in(wb, sheet, formula, MAX_POINTS, 0)
|
||||
}
|
||||
|
||||
fn eval_list_in(wb: &Workbook, sheet: usize, formula: &str, depth: u32) -> Vec<Value> {
|
||||
/// calc reads a reference of up to this many cells at its full size and trims a larger one to the
|
||||
/// sheet's used range (`FULL_SIZE_CELLS` in `gridcraft_calc::eval`). A clipped reference must stay
|
||||
/// above it, or the clip would turn a trimmed read into a full-size one with trailing blanks.
|
||||
const CALC_FULL_SIZE_CELLS: u64 = 16_384;
|
||||
/// Bounded references up to this many cells are read at their full size too
|
||||
/// (`FULL_SIZE_BOUNDED_CELLS` in `gridcraft_calc::eval`).
|
||||
const CALC_FULL_SIZE_BOUNDED_CELLS: u64 = 1_048_576;
|
||||
|
||||
/// `formula` as a plain reference (`Sheet!A1:A1000000`) cut after the rows that can still hold
|
||||
/// one of the first `limit` points, so that calc never reads the rest; `None` when `formula` is
|
||||
/// no reference or already small enough. Values are read row by row, and calc trims only the
|
||||
/// bottom and right of a reference, so keeping `limit` rows keeps the first `limit` values.
|
||||
fn clipped_reference(wb: &Workbook, sheet: usize, formula: &str, limit: usize) -> Option<String> {
|
||||
let (sh, mut r) = parse_ref(wb, sheet, formula)?;
|
||||
let cells = u64::from(r.height()).saturating_mul(u64::from(r.width()));
|
||||
// calc trims a block that runs to the sheet's edge, and any block over a column's worth, to
|
||||
// the used range. Do it here, or the clipped block below would be read at its full size.
|
||||
let to_edge = r.end.row == MAX_ROWS - 1 || r.end.col == MAX_COLS - 1;
|
||||
let trimmed = cells > CALC_FULL_SIZE_CELLS && (to_edge || cells > CALC_FULL_SIZE_BOUNDED_CELLS);
|
||||
let mut changed = false;
|
||||
if trimmed {
|
||||
let end = match wb.sheet(sh).and_then(|s| s.used_range()) {
|
||||
Some(u) => CellRef::new(r.end.row.min(u.end.row.max(r.start.row)), r.end.col.min(u.end.col.max(r.start.col))),
|
||||
None => r.start,
|
||||
};
|
||||
changed = end != r.end;
|
||||
r = RangeRef::new(r.start, end);
|
||||
}
|
||||
let prefix = formula.rfind('!').and_then(|i| formula.get(..=i)).unwrap_or("");
|
||||
let (h, w) = (u64::from(r.height()), u64::from(r.width()));
|
||||
if h.saturating_mul(w) > CALC_FULL_SIZE_CELLS {
|
||||
let keep = (limit as u64).max(CALC_FULL_SIZE_CELLS / w + 1);
|
||||
if h > keep {
|
||||
let end = CellRef::new(r.start.row.saturating_add(u32::try_from(keep).ok()?).saturating_sub(1), r.end.col);
|
||||
r = RangeRef::new(r.start, end);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
changed.then(|| format!("{prefix}{r}"))
|
||||
}
|
||||
|
||||
fn eval_list_in(wb: &Workbook, sheet: usize, formula: &str, limit: usize, depth: u32) -> Vec<Value> {
|
||||
let f = formula.trim();
|
||||
if f.is_empty() {
|
||||
return vec![];
|
||||
@@ -90,16 +140,17 @@ fn eval_list_in(wb: &Workbook, sheet: usize, formula: &str, depth: u32) -> Vec<V
|
||||
{
|
||||
let mut out = Vec::new();
|
||||
for p in parts {
|
||||
if out.len() >= MAX_POINTS {
|
||||
if out.len() >= limit {
|
||||
break;
|
||||
}
|
||||
let rest = MAX_POINTS - out.len();
|
||||
out.extend(eval_list_in(wb, sheet, p, depth + 1).into_iter().take(rest));
|
||||
let rest = limit - out.len();
|
||||
out.extend(eval_list_in(wb, sheet, p, rest, depth + 1).into_iter().take(rest));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
match evaluate(wb, sheet, CellRef::default(), f) {
|
||||
Value::Array(a) => a.iter().take(MAX_POINTS).cloned().collect(),
|
||||
let clipped = clipped_reference(wb, sheet, f, limit);
|
||||
match evaluate(wb, sheet, CellRef::default(), clipped.as_deref().unwrap_or(f)) {
|
||||
Value::Array(a) => a.iter().take(limit).cloned().collect(),
|
||||
v => vec![v],
|
||||
}
|
||||
}
|
||||
@@ -111,8 +162,8 @@ fn to_num(v: &Value) -> Option<f64> {
|
||||
}
|
||||
}
|
||||
|
||||
fn nums(wb: &Workbook, sheet: usize, formula: &str) -> Vec<Option<f64>> {
|
||||
eval_list(wb, sheet, formula).iter().map(to_num).collect()
|
||||
fn nums(wb: &Workbook, sheet: usize, formula: &str, limit: usize) -> Vec<Option<f64>> {
|
||||
eval_list_in(wb, sheet, formula, limit, 0).iter().map(to_num).collect()
|
||||
}
|
||||
|
||||
/// Number format code of the first cell of a reference ("General" when not a reference).
|
||||
@@ -133,38 +184,113 @@ fn value_text(v: &Value) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Characters kept of one category label: an axis label never shows more (labels are fitted to
|
||||
/// the axis), and copying more only costs memory.
|
||||
const MAX_LABEL_CHARS: usize = 255;
|
||||
/// Bytes of category label text kept per series. One long text broadcast by a short formula
|
||||
/// (`IF(SEQUENCE(100000), long_text)`) is a single shared value, but turning every element into
|
||||
/// its own label copied it 100,000 times (gigabytes). Labels past the budget are empty.
|
||||
const MAX_CATEGORY_BYTES: usize = 8 << 20;
|
||||
|
||||
/// At most [`MAX_LABEL_CHARS`] characters and `room` bytes of `s`, cut at a character boundary.
|
||||
fn clip_label(s: &str, room: usize) -> String {
|
||||
let mut end = 0;
|
||||
for (n, (i, c)) in s.char_indices().enumerate() {
|
||||
if n >= MAX_LABEL_CHARS || i + c.len_utf8() > room {
|
||||
break;
|
||||
}
|
||||
end = i + c.len_utf8();
|
||||
}
|
||||
s.get(..end).unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
/// The label of category `i` (value `v`) in at most `room` bytes: formatted with its source
|
||||
/// cell's number format when it is a number.
|
||||
fn category_label(wb: &Workbook, src: Option<(&gridcraft_model::Sheet, RangeRef)>, i: usize, v: &Value, room: usize) -> String {
|
||||
if room == 0 {
|
||||
return String::new(); // nothing fits: skip the number formatting
|
||||
}
|
||||
if let (Some((s, r)), Value::Number(_)) = (src, v) {
|
||||
let w = r.width().max(1) as usize;
|
||||
let at = r.start.offset((i / w) as i64, (i % w) as i64);
|
||||
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 clip_label(&format_value(v, &NumberFormat::parse_cached(code), wb.date_system).text, room);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Text is clipped before it is copied: the source may be one long shared string.
|
||||
let mut v = v;
|
||||
while let Value::Array(a) = v {
|
||||
v = a.get(0, 0).unwrap_or(&Value::Empty);
|
||||
}
|
||||
match v {
|
||||
Value::Text(t) => clip_label(t, room),
|
||||
_ => clip_label(&value_text(v), room),
|
||||
}
|
||||
}
|
||||
|
||||
/// Category labels, formatted with each source cell's number format (dates, currency…), and
|
||||
/// the outer levels of a multi-level axis: a category range wider than a column of values (or
|
||||
/// taller than a row of values) groups its innermost labels, year beside month. Each outer level
|
||||
/// has one entry per category, non-empty where a group starts, as the cells are.
|
||||
fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str) -> (Vec<String>, Vec<Vec<String>>) {
|
||||
let vals = eval_list(wb, sheet, formula);
|
||||
fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str, limit: usize) -> (Vec<String>, Vec<Vec<String>>) {
|
||||
let cells: Option<(usize, RangeRef)> = parse_ref(wb, sheet, formula);
|
||||
let value_range = parse_ref(wb, sheet, values).map(|(_, r)| r);
|
||||
// Bound the category dimension by `limit` and read only the cells the levels use (the
|
||||
// innermost label line and up to three outer ones), so that every kept category keeps its
|
||||
// cells in every level. Counting raw cells would cut a two-level axis in the middle.
|
||||
let levels = cells.and_then(|(_, r)| category_levels(&r, value_range.as_ref()));
|
||||
let (cells, read) = match (cells, levels) {
|
||||
(Some((sh, r)), Some(lv)) => {
|
||||
let cut = u32::try_from(limit.saturating_sub(1)).unwrap_or(u32::MAX);
|
||||
let outer = lv.outer;
|
||||
let end = if lv.by_columns {
|
||||
CellRef::new(r.end.row.min(r.start.row.saturating_add(cut)), r.end.col)
|
||||
} else {
|
||||
CellRef::new(r.end.row, r.end.col.min(r.start.col.saturating_add(cut)))
|
||||
};
|
||||
let start = if lv.by_columns {
|
||||
CellRef::new(r.start.row, r.start.col.max(end.col.saturating_sub(outer)))
|
||||
} else {
|
||||
CellRef::new(r.start.row.max(end.row.saturating_sub(outer)), r.start.col)
|
||||
};
|
||||
let r = RangeRef::new(start, end);
|
||||
let prefix = formula.rfind('!').and_then(|i| formula.get(..=i)).unwrap_or("");
|
||||
let cap = (r.width() as usize).saturating_mul(r.height() as usize);
|
||||
(Some((sh, r)), Some((format!("{prefix}{r}"), cap)))
|
||||
}
|
||||
(c, _) => (c, None),
|
||||
};
|
||||
let vals = match &read {
|
||||
Some((f, cap)) => eval_list_in(wb, sheet, f, *cap, 0),
|
||||
None => eval_list_in(wb, sheet, formula, limit, 0),
|
||||
};
|
||||
let src = cells.and_then(|(sh, r)| wb.sheet(sh).map(|s| (s, r)));
|
||||
// The retained levels are known before any label is charged: a cell on a discarded level
|
||||
// gets no label and spends none of the budget. `None`: a flat list (every cell is a label).
|
||||
let layout = src.and_then(|(_, r)| {
|
||||
let levels = levels?;
|
||||
let (w, h) = (r.width().max(1) as usize, r.height().max(1) as usize);
|
||||
(vals.len() == w.saturating_mul(h)).then_some((levels, w, h)) // else truncated or not a plain block
|
||||
});
|
||||
let kept =
|
||||
|i: usize| layout.is_none_or(|(l, w, h)| if l.by_columns { i % w + 1 + l.outer as usize >= w } else { i / w + 1 + l.outer as usize >= h });
|
||||
let mut room = MAX_CATEGORY_BYTES;
|
||||
let texts: Vec<String> = vals
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| {
|
||||
if let (Some((s, r)), Value::Number(_)) = (src, v) {
|
||||
let w = r.width().max(1) as usize;
|
||||
let at = r.start.offset((i / w) as i64, (i % w) as i64);
|
||||
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_cached(code), wb.date_system).text;
|
||||
}
|
||||
}
|
||||
if !kept(i) {
|
||||
return String::new();
|
||||
}
|
||||
value_text(v)
|
||||
let label = category_label(wb, src, i, v, room);
|
||||
room = room.saturating_sub(label.len());
|
||||
label
|
||||
})
|
||||
.collect();
|
||||
let Some((_, r)) = src else { return (texts, vec![]) };
|
||||
let value_range = parse_ref(wb, sheet, values).map(|(_, r)| r);
|
||||
let Some(levels) = category_levels(&r, value_range.as_ref()) else { return (texts, vec![]) };
|
||||
let (w, h) = (r.width().max(1) as usize, r.height().max(1) as usize);
|
||||
if texts.len() != w.saturating_mul(h) {
|
||||
return (texts, vec![]); // truncated or not a plain block: a flat list
|
||||
}
|
||||
let Some((levels, w, h)) = layout else { return (texts, vec![]) };
|
||||
let at = |row: usize, col: usize| texts.get(row * w + col).cloned().unwrap_or_default();
|
||||
let column = |c: usize| (0..h).map(|row| at(row, c)).collect::<Vec<String>>();
|
||||
let row = |rr: usize| (0..w).map(|c| at(rr, c)).collect::<Vec<String>>();
|
||||
@@ -201,7 +327,7 @@ fn series_name(wb: &Workbook, sheet: usize, name: &str) -> String {
|
||||
t.to_string()
|
||||
}
|
||||
|
||||
fn effective_kind(chart_kind: ChartKind, series_kind: Option<ChartKind>, i: usize) -> ChartKind {
|
||||
pub(crate) fn effective_kind(chart_kind: ChartKind, series_kind: Option<ChartKind>, i: usize) -> ChartKind {
|
||||
match (chart_kind, series_kind) {
|
||||
(ChartKind::Combo, Some(k)) if k != ChartKind::Combo => k,
|
||||
(ChartKind::Combo, _) => {
|
||||
@@ -216,9 +342,15 @@ fn effective_kind(chart_kind: ChartKind, series_kind: Option<ChartKind>, i: usiz
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind series `i` is drawn as: its own kind in a combo chart (first series a column, the
|
||||
/// rest lines when it has none), else the chart's. The one rule shared by resolving and drawing.
|
||||
pub(crate) fn plot_kind(chart_kind: ChartKind, series_kind: Option<ChartKind>, i: usize) -> ChartKind {
|
||||
if chart_kind == ChartKind::Combo { effective_kind(chart_kind, series_kind, i) } else { chart_kind }
|
||||
}
|
||||
|
||||
/// The kind series `i` of `chart` is drawn as: its own kind in a combo chart, else the chart's.
|
||||
pub fn series_plot_kind(chart: &Chart, i: usize) -> ChartKind {
|
||||
if chart.kind == ChartKind::Combo { effective_kind(chart.kind, chart.series.get(i).and_then(|s| s.kind), i) } else { chart.kind }
|
||||
plot_kind(chart.kind, chart.series.get(i).and_then(|s| s.kind), i)
|
||||
}
|
||||
|
||||
/// Evaluates a chart's series formulas: names, values, categories (default "1", "2", …), X
|
||||
@@ -228,37 +360,43 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
|
||||
let numeric_x = matches!(chart.kind, ChartKind::Scatter | ChartKind::ScatterLines | ChartKind::Bubble);
|
||||
let mut series = Vec::new();
|
||||
let mut categories: Option<Vec<String>> = None;
|
||||
let limit = points_per_series(chart.series.len());
|
||||
let mut trend_left = MAX_TRENDLINES;
|
||||
let mut category_levels = Vec::new();
|
||||
for (i, s) in chart.series.iter().take(MAX_SERIES).enumerate() {
|
||||
let values = nums(wb, sheet, &s.values);
|
||||
let values = nums(wb, sheet, &s.values, limit);
|
||||
let name = s.name.as_deref().map(|n| series_name(wb, sheet, n)).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Series{}", i + 1));
|
||||
let color =
|
||||
s.color.as_ref().and_then(|c| c.resolve(&wb.theme)).map(|[r, g, b]| [r, g, b, 0xFF]).unwrap_or_else(|| series_color(&wb.theme, i));
|
||||
let kind = effective_kind(chart.kind, s.kind, i);
|
||||
let x = match (&s.categories, numeric_x || matches!(kind, ChartKind::Scatter | ChartKind::ScatterLines | ChartKind::Bubble)) {
|
||||
// X values with text in them (labels) are ignored: the points are numbered 1..n.
|
||||
(Some(f), true) => Some(eval_list(wb, sheet, f)).filter(|xs| !xs.iter().any(Value::is_text)).map(|xs| xs.iter().map(to_num).collect()),
|
||||
(Some(f), true) => {
|
||||
Some(eval_list_in(wb, sheet, f, limit, 0)).filter(|xs| !xs.iter().any(Value::is_text)).map(|xs| xs.iter().map(to_num).collect())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if categories.is_none()
|
||||
&& let Some(f) = &s.categories
|
||||
{
|
||||
let (c, outer) = category_table(wb, sheet, f, &s.values);
|
||||
let (mut c, outer) = category_table(wb, sheet, f, &s.values, limit);
|
||||
c.truncate(limit);
|
||||
if !c.is_empty() {
|
||||
categories = Some(c);
|
||||
category_levels = outer;
|
||||
}
|
||||
}
|
||||
let sizes = s.bubble_sizes.as_deref().map(|f| nums(wb, sheet, f));
|
||||
let sizes = s.bubble_sizes.as_deref().map(|f| nums(wb, sheet, f, limit));
|
||||
let trendlines = s
|
||||
.trendlines
|
||||
.iter()
|
||||
.take(MAX_TRENDLINES)
|
||||
.take(trend_left)
|
||||
.map(|t| TrendlineData {
|
||||
spec: t.clone(),
|
||||
color: t.color.as_ref().and_then(|c| c.resolve(&wb.theme)).map(|[r, g, b]| [r, g, b, 0xFF]).unwrap_or(color),
|
||||
})
|
||||
.collect();
|
||||
.collect::<Vec<_>>();
|
||||
trend_left = trend_left.saturating_sub(trendlines.len());
|
||||
series.push(SeriesData {
|
||||
name,
|
||||
values,
|
||||
@@ -277,3 +415,109 @@ pub fn resolve(wb: &Workbook, sheet: usize, chart: &Chart) -> ChartData {
|
||||
let palette = default_palette(&wb.theme, n.min(PALETTE_CYCLE));
|
||||
ChartData { categories, category_levels, series, palette }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod label_budget_tests {
|
||||
use gridcraft_model::Workbook;
|
||||
|
||||
/// Security: category labels are clipped before they are copied, with one byte budget per
|
||||
/// series. A formula broadcasting one long text to 100,000 categories used to copy it
|
||||
/// 100,000 times (3.3 GB).
|
||||
#[test]
|
||||
fn category_labels_share_one_byte_budget() {
|
||||
let wb = Workbook::new();
|
||||
let (texts, _) = super::category_table(&wb, 0, r#"=IF(SEQUENCE(100000),REPT("x",32767))"#, "", super::MAX_POINTS);
|
||||
assert_eq!(texts.len(), 100_000);
|
||||
assert_eq!(texts.first().map(String::len), Some(super::MAX_LABEL_CHARS));
|
||||
assert!(texts.iter().map(String::len).sum::<usize>() <= super::MAX_CATEGORY_BYTES);
|
||||
// Labels are cut at character boundaries.
|
||||
assert_eq!(super::clip_label("aé", 2), "a");
|
||||
assert_eq!(super::clip_label("aé", 3), "aé");
|
||||
}
|
||||
|
||||
/// Cells on discarded outer levels (only the innermost labels and three outer levels are
|
||||
/// kept) spend none of the byte budget.
|
||||
#[test]
|
||||
fn discarded_levels_do_not_spend_the_label_budget() {
|
||||
let mut wb = Workbook::new();
|
||||
let rows = 8000u32;
|
||||
if let Some(sh) = wb.sheet_mut(0) {
|
||||
for r in 0..rows {
|
||||
for c in 0..5 {
|
||||
sh.set_value(gridcraft_core::CellRef::new(r, c), gridcraft_core::Value::text("x".repeat(255)));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Five columns: column A is a fourth outer level and is discarded.
|
||||
let (inner, outer) = super::category_table(&wb, 0, "=A1:E8000", "", super::MAX_POINTS);
|
||||
assert_eq!(outer.len(), 3);
|
||||
assert_eq!(inner.last().map(String::len), Some(super::MAX_LABEL_CHARS));
|
||||
assert_eq!(outer.first().and_then(|l| l.last()).map(String::len), Some(super::MAX_LABEL_CHARS));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use gridcraft_model::Workbook;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn book(rows: u32, cols: u32) -> Workbook {
|
||||
let mut wb = Workbook::new();
|
||||
if let Some(s) = wb.sheet_mut(0) {
|
||||
s.name = "My Sheet".into();
|
||||
for r in 0..rows {
|
||||
for c in 0..cols {
|
||||
s.set_value(CellRef::new(r, c), Value::Number(f64::from(r * 10 + c)));
|
||||
}
|
||||
}
|
||||
}
|
||||
wb
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_reference_is_clipped_before_it_is_evaluated() {
|
||||
let wb = book(120_000, 3);
|
||||
let rows = |f: &str, limit| {
|
||||
let g = clipped_reference(&wb, 0, f, limit)?;
|
||||
let (_, r) = parse_ref(&wb, 0, &g)?;
|
||||
Some((g, u64::from(r.height())))
|
||||
};
|
||||
// 1,000,000 rows: at most `MAX_POINTS` of them are asked for.
|
||||
assert_eq!(rows("'My Sheet'!$A$1:$A$1000000", MAX_POINTS), Some(("'My Sheet'!A1:A100000".to_string(), 100_000)));
|
||||
assert_eq!(rows("A5:A1048576", MAX_POINTS).map(|r| r.1), Some(100_000));
|
||||
assert_eq!(rows("A:A", 50_000).map(|r| r.1), Some(50_000));
|
||||
// Never below what keeps calc trimming the clipped reference like the whole one.
|
||||
assert_eq!(rows("A1:A1000000", 100).map(|r| r.1), Some(16_385));
|
||||
// Wide references keep whole rows.
|
||||
assert_eq!(rows("A1:C1000000", MAX_POINTS).map(|r| r.1), Some(100_000));
|
||||
// Small references, single cells and anything that isn't a plain reference are left alone.
|
||||
assert_eq!(rows("A1:A9000", MAX_POINTS), None);
|
||||
assert_eq!(rows("A1:A20000", MAX_POINTS), None);
|
||||
assert_eq!(rows("B2", MAX_POINTS), None);
|
||||
assert_eq!(rows("SUM(A1:A1000000)", MAX_POINTS), None);
|
||||
assert_eq!(rows("{1,2,3}", MAX_POINTS), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipping_does_not_change_the_points() {
|
||||
for (rows, cols) in [(10, 1), (30_000, 1), (30_000, 2), (3, 4)] {
|
||||
let wb = book(rows, cols);
|
||||
for f in ["'My Sheet'!A1:A1000000", "'My Sheet'!$A$3:$B$1048576", "A:A", "A1:C1000000", "(A1:A1000000,B1:B1000000)", "A2:A20000"] {
|
||||
for limit in [100, 7_843, 25_000, MAX_POINTS] {
|
||||
let clipped = eval_list_in(&wb, 0, f, limit, 0);
|
||||
let whole: Vec<Value> = match evaluate(&wb, 0, CellRef::default(), f) {
|
||||
Value::Array(a) => a.iter().take(limit).cloned().collect(),
|
||||
v => vec![v],
|
||||
};
|
||||
if f.starts_with('(') {
|
||||
// A union is read part by part; each part keeps its own order.
|
||||
assert!(clipped.len() <= limit);
|
||||
continue;
|
||||
}
|
||||
assert_eq!(clipped, whole, "{f} limit {limit} on {rows}x{cols}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ use gridcraft_model::{Anchor, Chart, ChartKind, Color, LegendPos, Series, Sparkl
|
||||
|
||||
use crate::*;
|
||||
|
||||
mod budget;
|
||||
mod data_table;
|
||||
|
||||
const ALL_KINDS: [ChartKind; 26] = [
|
||||
@@ -1418,6 +1419,38 @@ fn area_charts_draw_source_value_labels_only_when_enabled() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drawing_and_resolving_agree_on_the_combo_plot_kind() {
|
||||
// Resolved series carry `effective_kind`; drawing re-derives the kind from it. Both paths
|
||||
// must give the same kind for every chart kind, series kind (or none) and series index.
|
||||
let series_kinds = std::iter::once(None).chain(ALL_KINDS.iter().map(|k| Some(*k)));
|
||||
for ck in ALL_KINDS {
|
||||
for sk in series_kinds.clone() {
|
||||
for i in 0..3 {
|
||||
let mut c = chart(ck);
|
||||
c.series = (0..3).map(|j| Series { kind: if j == i { sk } else { None }, ..Default::default() }).collect();
|
||||
let from_chart = series_plot_kind(&c, i);
|
||||
let resolved = crate::resolve::effective_kind(ck, sk, i);
|
||||
assert_eq!(crate::resolve::plot_kind(ck, Some(resolved), i), from_chart, "{ck:?} {sk:?} {i}");
|
||||
if ck != ChartKind::Combo {
|
||||
assert_eq!(from_chart, ck);
|
||||
} else {
|
||||
assert_ne!(from_chart, ChartKind::Combo);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticks_of_a_scale_off_the_step_grid_start_at_its_min() {
|
||||
let s = Scale { min: 3.0, max: 7.0, step: 4.0 };
|
||||
assert_eq!(s.ticks(), vec![3.0, 7.0]);
|
||||
// Aligned scales still snap float error away.
|
||||
let s = Scale { min: 0.0, max: 1.0, step: 0.1 };
|
||||
assert!((s.ticks()[3] - 0.3).abs() < 1e-12 && s.ticks()[0] == 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hundred_percent_stacked_areas_fill_each_category_to_its_total() {
|
||||
let d = ChartData {
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
//! Work and memory are bounded by the product of the chart limits (series x points, trendlines
|
||||
//! per chart, hairline bars), and clean data is rendered without a copy.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use gridcraft_core::{CellRef, Value};
|
||||
use gridcraft_model::{ChartKind, Series, Trendline, TrendlineKind, Workbook};
|
||||
|
||||
use super::{assert_sane, chart};
|
||||
use crate::render::{sanitize, sanitized};
|
||||
use crate::resolve::{MAX_POINTS, MAX_TOTAL_POINTS, MAX_TRENDLINES, points_per_series};
|
||||
use crate::*;
|
||||
|
||||
fn data_of(series: usize, points: usize) -> ChartData {
|
||||
ChartData {
|
||||
palette: vec![],
|
||||
categories: (0..points).map(|i| i.to_string()).collect(),
|
||||
category_levels: vec![],
|
||||
series: (0..series)
|
||||
.map(|s| SeriesData {
|
||||
name: format!("S{s}"),
|
||||
values: (0..points).map(|i| Some(((i * 7 + s * 13) % 100 + 1) as f64)).collect(),
|
||||
color: series_color(&Default::default(), s),
|
||||
..SeriesData::default()
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn series_rects(prims: &[Prim], color: Rgba) -> Vec<(f32, f32, f32, f32)> {
|
||||
prims
|
||||
.iter()
|
||||
.filter_map(|p| match p {
|
||||
Prim::Rect { x, y, w, h, fill: Some(f), .. } if *f == color => Some((*x, *y, *w, *h)),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_point_budget_keeps_every_series_with_fewer_points() {
|
||||
assert_eq!(points_per_series(1), MAX_POINTS);
|
||||
assert_eq!(points_per_series(20), MAX_POINTS);
|
||||
assert_eq!(points_per_series(21), MAX_TOTAL_POINTS / 21);
|
||||
assert_eq!(points_per_series(255), MAX_TOTAL_POINTS / 255);
|
||||
assert_eq!(points_per_series(0), MAX_POINTS);
|
||||
|
||||
// 25 series x 100,000 points is 2.5 million: over budget.
|
||||
let d = data_of(25, MAX_POINTS);
|
||||
let kept = sanitized(&d);
|
||||
assert_eq!(kept.series.len(), 25, "no series is dropped");
|
||||
assert!(kept.series.iter().all(|s| s.values.len() == MAX_TOTAL_POINTS / 25));
|
||||
assert_eq!(kept.categories.len(), MAX_TOTAL_POINTS / 25);
|
||||
assert!(kept.series.iter().map(|s| s.values.len()).sum::<usize>() <= MAX_TOTAL_POINTS);
|
||||
// The first points are the ones kept.
|
||||
assert_eq!(kept.series[3].values[..5], d.series[3].values[..5]);
|
||||
|
||||
// Drawing it stays bounded: hairline columns are merged per pixel column.
|
||||
let mut c = chart(ChartKind::ColumnClustered);
|
||||
c.data_labels = false;
|
||||
c.legend = LegendPos::None;
|
||||
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
|
||||
assert_sane(&prims, 400.0, 300.0);
|
||||
assert!(prims.len() < 25 * 450 + 200, "{} primitives", prims.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trendlines_are_limited_per_chart() {
|
||||
let line = |kind| TrendlineData { spec: Trendline { kind, ..Trendline::default() }, color: [1, 2, 3, 255] };
|
||||
let mut d = data_of(3, 30);
|
||||
for s in &mut d.series {
|
||||
s.trendlines = vec![line(TrendlineKind::Linear); 10];
|
||||
}
|
||||
let kept = sanitized(&d);
|
||||
let counts: Vec<usize> = kept.series.iter().map(|s| s.trendlines.len()).collect();
|
||||
assert_eq!(counts, vec![10, MAX_TRENDLINES - 10, 0], "series order decides which trendlines stay");
|
||||
let entries = legend_entries(&chart(ChartKind::Line), &kept);
|
||||
assert_eq!(entries.len(), 3 + MAX_TRENDLINES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hairline_columns_are_merged_and_wide_ones_are_not() {
|
||||
let mut c = chart(ChartKind::ColumnClustered);
|
||||
c.data_labels = false;
|
||||
c.legend = LegendPos::None;
|
||||
|
||||
// Wide bars (>= 0.25 px): one rectangle per series and category, as before.
|
||||
let d = data_of(3, 40);
|
||||
let prims = render(&c, &d, 640.0, 400.0, &ApproxMeasure);
|
||||
for s in &d.series {
|
||||
assert_eq!(series_rects(&prims, s.color).len(), 40);
|
||||
}
|
||||
|
||||
// 4 series x 20,000 categories in 400 px: the bars are hairlines.
|
||||
let d = data_of(4, 20_000);
|
||||
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
|
||||
assert_sane(&prims, 400.0, 300.0);
|
||||
for s in &d.series {
|
||||
let n = series_rects(&prims, s.color).len();
|
||||
assert!((1..=420).contains(&n), "{n} rectangles for one series");
|
||||
}
|
||||
|
||||
// The merged rectangles keep the envelope: alternating 1 and 10 draws the full-height bar.
|
||||
let mut d = data_of(1, 20_000);
|
||||
for (i, v) in d.series[0].values.iter_mut().enumerate() {
|
||||
*v = Some(if i % 2 == 0 { 1.0 } else { 10.0 });
|
||||
}
|
||||
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
|
||||
let rects = series_rects(&prims, d.series[0].color);
|
||||
let tallest = rects.iter().map(|r| r.3).fold(0.0f32, f32::max);
|
||||
assert!(rects.len() > 100 && tallest > 100.0);
|
||||
assert!(rects.iter().all(|r| (r.3 - tallest).abs() < 0.01), "every pixel column shows the tall bar");
|
||||
// And together they cover the plot's width without a gap wider than a pixel.
|
||||
let mut xs: Vec<(f32, f32)> = rects.iter().map(|r| (r.0, r.0 + r.2)).collect();
|
||||
xs.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
assert!(xs.windows(2).all(|w| w[1].0 - w[0].1 < 1.0));
|
||||
|
||||
// Horizontal bars too.
|
||||
let mut c = chart(ChartKind::BarClustered);
|
||||
c.data_labels = false;
|
||||
c.legend = LegendPos::None;
|
||||
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
|
||||
let n = series_rects(&prims, d.series[0].color).len();
|
||||
assert!((1..=320).contains(&n), "{n} bars");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clustered_hairline_series_keep_a_visible_strip() {
|
||||
// Four series of similar height, 20,000 categories in 400 px: every pixel column holds a
|
||||
// merged bar of each series, and they must not cover each other.
|
||||
for kind in [ChartKind::ColumnClustered, ChartKind::BarClustered] {
|
||||
let horizontal = kind == ChartKind::BarClustered;
|
||||
let mut c = chart(kind);
|
||||
c.data_labels = false;
|
||||
c.legend = LegendPos::None;
|
||||
let mut d = data_of(4, 20_000);
|
||||
for (s, series) in d.series.iter_mut().enumerate() {
|
||||
for (i, v) in series.values.iter_mut().enumerate() {
|
||||
*v = Some(50.0 + ((i + s) % 3) as f64);
|
||||
}
|
||||
}
|
||||
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
|
||||
let along = |r: &(f32, f32, f32, f32)| if horizontal { (r.1, r.1 + r.3) } else { (r.0, r.0 + r.2) };
|
||||
let mut strips: Vec<(f32, f32, usize)> = Vec::new();
|
||||
for (s, series) in d.series.iter().enumerate() {
|
||||
strips.extend(series_rects(&prims, series.color).iter().map(|r| (along(r).0, along(r).1, s)));
|
||||
}
|
||||
strips.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
assert!(strips.len() > 400);
|
||||
// Rectangles of different series never overlap (more than rounding).
|
||||
for w in strips.windows(2) {
|
||||
assert!(w[0].2 == w[1].2 || w[1].0 >= w[0].1 - 1e-3, "series {} and {} overlap", w[0].2, w[1].2);
|
||||
}
|
||||
// And all four series show up in the same pixel column.
|
||||
let col = strips[strips.len() / 2].0.floor();
|
||||
let seen: std::collections::BTreeSet<usize> = strips.iter().filter(|s| s.0.floor() == col).map(|s| s.2).collect();
|
||||
assert_eq!(seen.len(), 4, "series visible in pixel column {col}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stacked_hairline_bars_do_not_fill_the_gap_between_disjoint_spans() {
|
||||
// Series 1 alternates +10 (stacked on the 100) and -10 (below zero): its two spans in one
|
||||
// pixel column are 100..110 and -10..0, and the 100 units between them must stay empty.
|
||||
let mut c = chart(ChartKind::ColumnStacked);
|
||||
c.data_labels = false;
|
||||
c.legend = LegendPos::None;
|
||||
let mut d = data_of(2, 20_000);
|
||||
for v in d.series[0].values.iter_mut() {
|
||||
*v = Some(100.0);
|
||||
}
|
||||
for (i, v) in d.series[1].values.iter_mut().enumerate() {
|
||||
*v = Some(if i % 2 == 0 { 10.0 } else { -10.0 });
|
||||
}
|
||||
let prims = render(&c, &d, 400.0, 300.0, &ApproxMeasure);
|
||||
let rects = series_rects(&prims, d.series[1].color);
|
||||
let tallest = rects.iter().map(|r| r.3).fold(0.0f32, f32::max);
|
||||
assert!(!rects.is_empty() && tallest < 60.0, "tallest merged bar is {tallest} px");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_data_is_rendered_without_a_copy() {
|
||||
let d = data_of(3, 50);
|
||||
assert!(matches!(sanitized(&d), Cow::Borrowed(_)));
|
||||
|
||||
// Dirty data takes the copying path and comes out as `sanitize` has always produced it.
|
||||
let mut dirty = data_of(3, 50);
|
||||
dirty.series[0].values[4] = Some(f64::NAN);
|
||||
dirty.series[1].values[5] = Some(f64::INFINITY);
|
||||
dirty.series[2].values[6] = Some(1e301);
|
||||
dirty.series[2].x = Some(vec![Some(f64::NEG_INFINITY), Some(2.0)]);
|
||||
dirty.series[1].sizes = Some(vec![Some(-1e305)]);
|
||||
let got = sanitized(&dirty);
|
||||
assert!(matches!(got, Cow::Owned(_)));
|
||||
assert_eq!(*got, sanitize(&dirty));
|
||||
assert_eq!(got.series[0].values[4], None);
|
||||
assert_eq!(got.series[2].values[6], Some(1e300));
|
||||
|
||||
// Over the series cap (within the point budget) the same.
|
||||
let many = data_of(300, 3);
|
||||
let got = sanitized(&many);
|
||||
assert_eq!(got.series.len(), 255);
|
||||
assert_eq!(*got, sanitize(&many));
|
||||
|
||||
// Too many category levels take the copying path too.
|
||||
let mut levels = data_of(3, 50);
|
||||
levels.category_levels = vec![vec!["a".into()]; 5];
|
||||
let got = sanitized(&levels);
|
||||
assert!(matches!(got, Cow::Owned(_)));
|
||||
assert_eq!(got.category_levels.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_keeps_every_series_within_the_point_budget() {
|
||||
// 40 series x 60,000 points is 2.4 million: over the 2 million budget.
|
||||
let mut wb = Workbook::new();
|
||||
if let Some(s) = wb.sheet_mut(0) {
|
||||
for r in 0..60_000u32 {
|
||||
s.set_value(CellRef::new(r, 0), Value::Number(f64::from(r)));
|
||||
}
|
||||
}
|
||||
let mut c = chart(ChartKind::ColumnClustered);
|
||||
c.series =
|
||||
(0..40).map(|_| Series { values: "Sheet1!$A$1:$A$1000000".into(), categories: Some("A1:A1000000".into()), ..Series::default() }).collect();
|
||||
let d = resolve(&wb, 0, &c);
|
||||
assert_eq!(d.series.len(), 40);
|
||||
assert!(d.series.iter().all(|s| s.values.len() == MAX_TOTAL_POINTS / 40));
|
||||
assert_eq!(d.categories.len(), MAX_TOTAL_POINTS / 40);
|
||||
assert_eq!(d.series[7].values.first(), Some(&Some(0.0)));
|
||||
// Trendlines: 16 per chart, in series order.
|
||||
c.series = (0..4).map(|_| Series { values: "A1:A5".into(), trendlines: vec![Default::default(); 9], ..Series::default() }).collect();
|
||||
let counts: Vec<usize> = resolve(&wb, 0, &c).series.iter().map(|s| s.trendlines.len()).collect();
|
||||
assert_eq!(counts, vec![9, 7, 0, 0]);
|
||||
}
|
||||
|
||||
/// 100 series lower the per-series cap to 20,000, below the number of category cells of a
|
||||
/// two-level axis (#1155). Each kept category keeps its inner label and its outer level.
|
||||
fn two_level_chart(n: u32, vertical: bool) -> ChartData {
|
||||
let at = |i: u32, level: u32| if vertical { CellRef::new(i, level) } else { CellRef::new(level, i) };
|
||||
let mut wb = Workbook::new();
|
||||
if let Some(s) = wb.sheet_mut(0) {
|
||||
for i in 0..n {
|
||||
s.set_value(at(i, 0), Value::Text(format!("Y{}", i / 3).into()));
|
||||
s.set_value(at(i, 1), Value::Text(format!("C{i}").into()));
|
||||
s.set_value(at(i, 2), Value::Number(f64::from(i)));
|
||||
}
|
||||
}
|
||||
let cats = format!("{}:{}", at(0, 0), at(n - 1, 1));
|
||||
let values = format!("{}:{}", at(0, 2), at(n - 1, 2));
|
||||
let mut c = chart(ChartKind::ColumnClustered);
|
||||
c.series = (0..100).map(|_| Series { values: values.clone(), categories: Some(cats.clone()), ..Series::default() }).collect();
|
||||
resolve(&wb, 0, &c)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_level_categories_keep_their_levels_under_the_point_cap() {
|
||||
for vertical in [true, false] {
|
||||
let d = two_level_chart(15_000, vertical);
|
||||
assert_eq!(d.series[0].values.len(), 15_000, "vertical {vertical}");
|
||||
assert_eq!(d.categories.len(), 15_000, "vertical {vertical}");
|
||||
assert_eq!(d.categories[0], "C0");
|
||||
assert_eq!(d.categories[14_999], "C14999");
|
||||
assert_eq!(d.category_levels.len(), 1, "one outer level, vertical {vertical}");
|
||||
assert_eq!(d.category_levels[0].len(), 15_000);
|
||||
assert_eq!(d.category_levels[0][3], "Y1");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_level_categories_longer_than_the_cap_are_cut_together_with_their_levels() {
|
||||
// 25,000 categories against a cap of 20,000.
|
||||
let d = two_level_chart(25_000, true);
|
||||
let cap = points_per_series(100);
|
||||
assert_eq!(d.categories.len(), cap);
|
||||
assert_eq!(d.category_levels.len(), 1);
|
||||
assert_eq!(d.category_levels[0].len(), cap);
|
||||
assert_eq!(d.categories[cap - 1], format!("C{}", cap - 1));
|
||||
assert_eq!(d.category_levels[0][cap - 3], format!("Y{}", (cap - 3) / 3));
|
||||
}
|
||||
@@ -1,6 +1,35 @@
|
||||
use super::*;
|
||||
use gridcraft_model::ChartDataTable;
|
||||
|
||||
#[test]
|
||||
fn combo_table_keys_use_each_series_index() {
|
||||
let mut c = chart(ChartKind::Combo);
|
||||
c.legend = LegendPos::None;
|
||||
c.data_labels = false;
|
||||
c.data_table = Some(ChartDataTable::default());
|
||||
let mut d = sample(c.kind);
|
||||
// The public renderer also accepts Combo series: the first defaults to columns, later ones to lines.
|
||||
for s in &mut d.series {
|
||||
s.kind = ChartKind::Combo;
|
||||
}
|
||||
let p = render(&c, &d, 900.0, 500.0, &ApproxMeasure);
|
||||
assert_sane(&p, 900.0, 500.0);
|
||||
let header_y = p
|
||||
.iter()
|
||||
.find_map(|p| match p {
|
||||
Prim::Text { text, y, .. } if text == "Q1" => Some(*y),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
for (i, s) in d.series.iter().enumerate() {
|
||||
let boxes = p.iter().filter(|p| matches!(p, Prim::Rect { y, fill: Some(f), .. } if *f == s.color && *y > header_y)).count();
|
||||
let lines =
|
||||
p.iter().filter(|p| matches!(p, Prim::Line { pts, color, .. } if *color == s.color && pts.iter().all(|p| p[1] > header_y))).count();
|
||||
assert_eq!(boxes, usize::from(i == 0), "series {i}: column key");
|
||||
assert_eq!(lines, usize::from(i > 0), "series {i}: line key");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tables_draw_source_values_for_every_supported_chart_kind() {
|
||||
for kind in ALL_KINDS {
|
||||
|
||||
+216
-2
@@ -72,7 +72,12 @@ pub(crate) fn fit_trendline(t: &Trendline, pts: &[(f64, f64)]) -> Option<TrendFi
|
||||
let mut aty = vec![0.0f64; m];
|
||||
for &(x, y) in &data {
|
||||
let u = (x - center) / scale;
|
||||
let pows: Vec<f64> = (first..=d).map(|k| u.powi(k as i32)).collect();
|
||||
// `d` is at most 6, so the powers `first..=d` always fit the array (no allocation per point).
|
||||
let mut buf = [0.0f64; 7];
|
||||
for (slot, k) in buf.iter_mut().zip(first..=d) {
|
||||
*slot = u.powi(k as i32);
|
||||
}
|
||||
let pows = buf.get(..m)?;
|
||||
for (i, pi) in pows.iter().enumerate() {
|
||||
if let Some(v) = aty.get_mut(i) {
|
||||
*v += pi * (y - b0);
|
||||
@@ -258,10 +263,45 @@ fn trim_zeros(s: &str) -> String {
|
||||
|
||||
/// Moving average of `period` consecutive points in plotted order: point `i` (from
|
||||
/// `period - 1`) is `(xᵢ, mean(yᵢ₋ₚ₊₁..=yᵢ))`. Empty when there are fewer points than `period`.
|
||||
///
|
||||
/// Linear in the number of points: the points are cut into blocks of `period`, and a window is
|
||||
/// the tail of one block plus the head of the next, taken from running sums that only ever add
|
||||
/// (a running sum that also subtracts would lose small values after a huge one passed through).
|
||||
pub(crate) fn moving_average(pts: &[(f64, f64)], period: u32) -> Vec<(f64, f64)> {
|
||||
let p = period.clamp(2, 255) as usize;
|
||||
let pts: Vec<(f64, f64)> = pts.iter().copied().filter(|(x, y)| x.is_finite() && y.is_finite()).collect();
|
||||
pts.windows(p).filter_map(|w| w.last().map(|(x, _)| (*x, w.iter().map(|q| q.1).sum::<f64>() / p as f64))).collect()
|
||||
let n = pts.len();
|
||||
if n < p {
|
||||
return vec![];
|
||||
}
|
||||
// `head[i]`: sum of the block's points up to and including `i`; `tail[i]`: from `i` to the block's end.
|
||||
let mut head = vec![0.0f64; n];
|
||||
let mut tail = vec![0.0f64; n];
|
||||
for (b, block) in pts.chunks(p).enumerate() {
|
||||
let (mut h, mut t) = (0.0f64, 0.0f64);
|
||||
for k in 0..block.len() {
|
||||
h += block.get(k).map_or(0.0, |q| q.1);
|
||||
if let Some(v) = head.get_mut(b * p + k) {
|
||||
*v = h;
|
||||
}
|
||||
let r = block.len() - 1 - k;
|
||||
t += block.get(r).map_or(0.0, |q| q.1);
|
||||
if let Some(v) = tail.get_mut(b * p + r) {
|
||||
*v = t;
|
||||
}
|
||||
}
|
||||
}
|
||||
(p - 1..n)
|
||||
.filter_map(|i| {
|
||||
let s = i + 1 - p;
|
||||
let mut sum = tail.get(s)? + if s.is_multiple_of(p) { 0.0 } else { *head.get(i)? };
|
||||
if !sum.is_finite() {
|
||||
// A block sum overflowed although the window itself may not: sum it directly.
|
||||
sum = pts.get(s..=i)?.iter().map(|q| q.1).sum();
|
||||
}
|
||||
Some((pts.get(i)?.0, sum / p as f64))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Excel's automatic trendline name for the legend, e.g. `Linear (Sales)`.
|
||||
@@ -276,3 +316,177 @@ pub(crate) fn auto_name(kind: TrendlineKind, series: &str) -> String {
|
||||
};
|
||||
format!("{k} ({series})")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// xorshift64 for repeatable pseudo-random data.
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
self.0
|
||||
}
|
||||
fn unit(&mut self) -> f64 {
|
||||
(self.next() % 1_000_000) as f64 / 1_000_000.0 - 0.5
|
||||
}
|
||||
}
|
||||
|
||||
/// The window-by-window moving average this module used to compute.
|
||||
fn moving_average_naive(pts: &[(f64, f64)], period: u32) -> Vec<(f64, f64)> {
|
||||
let p = period.clamp(2, 255) as usize;
|
||||
let pts: Vec<(f64, f64)> = pts.iter().copied().filter(|(x, y)| x.is_finite() && y.is_finite()).collect();
|
||||
pts.windows(p).filter_map(|w| w.last().map(|(x, _)| (*x, w.iter().map(|q| q.1).sum::<f64>() / p as f64))).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_moving_average_matches_the_window_sums() {
|
||||
let mut r = Rng(0x9E37_79B9_7F4A_7C15);
|
||||
for case in 0..60 {
|
||||
let n = (r.next() % 700) as usize;
|
||||
let pts: Vec<(f64, f64)> = (0..n)
|
||||
.map(|i| {
|
||||
let y = match case % 5 {
|
||||
0 => r.unit() * 100.0,
|
||||
1 => 1e9 + r.unit(),
|
||||
// A huge value passing through must not leave rounding residue behind.
|
||||
2 => {
|
||||
if r.next().is_multiple_of(50) {
|
||||
1e300 * r.unit().signum()
|
||||
} else {
|
||||
r.unit()
|
||||
}
|
||||
}
|
||||
3 => {
|
||||
if i % 2 == 0 {
|
||||
1e15
|
||||
} else {
|
||||
-1e15 + r.unit()
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if r.next().is_multiple_of(9) {
|
||||
f64::NAN
|
||||
} else {
|
||||
r.unit() * 1e-3
|
||||
}
|
||||
}
|
||||
};
|
||||
(i as f64, y)
|
||||
})
|
||||
.collect();
|
||||
for period in [0, 2, 3, 7, 50, 255, 400] {
|
||||
let fast = moving_average(&pts, period);
|
||||
let slow = moving_average_naive(&pts, period);
|
||||
assert_eq!(fast.len(), slow.len(), "case {case} period {period}");
|
||||
let p = period.clamp(2, 255) as usize;
|
||||
let finite: Vec<(f64, f64)> = pts.iter().copied().filter(|(x, y)| x.is_finite() && y.is_finite()).collect();
|
||||
for (i, ((fx, fy), (sx, sy))) in fast.iter().zip(&slow).enumerate() {
|
||||
assert_eq!(fx, sx);
|
||||
let mass: f64 = finite.iter().skip(i).take(p).map(|q| q.1.abs()).sum::<f64>() / p as f64;
|
||||
assert!((fy - sy).abs() <= 1e-12 * mass, "case {case} period {period} point {i}: {fy} vs {sy}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_average_recomputes_a_window_whose_block_sums_overflow() {
|
||||
let pts: Vec<(f64, f64)> = [0.0, -1e308, 1e308, 1e308, -1e308].iter().enumerate().map(|(i, y)| (i as f64, *y)).collect();
|
||||
let fast = moving_average(&pts, 4);
|
||||
let slow = moving_average_naive(&pts, 4);
|
||||
assert_eq!(fast, slow);
|
||||
assert!(fast.iter().all(|q| q.1.is_finite()));
|
||||
}
|
||||
|
||||
/// The trendline fit as it was written with a `Vec` of powers per data point.
|
||||
fn fit_reference(t: &Trendline, pts: &[(f64, f64)]) -> Option<TrendFit> {
|
||||
let kind = t.kind;
|
||||
let d = degree(kind)?;
|
||||
let data: Vec<(f64, f64)> = pts
|
||||
.iter()
|
||||
.filter(|(x, y)| x.is_finite() && y.is_finite() && (!log_x(kind) || *x > 0.0) && (!log_y(kind) || *y > 0.0))
|
||||
.map(|&(x, y)| (if log_x(kind) { x.ln() } else { x }, if log_y(kind) { y.ln() } else { y }))
|
||||
.collect();
|
||||
if data.len() < 2 || data.len() <= d {
|
||||
return None;
|
||||
}
|
||||
let fixed = match kind {
|
||||
TrendlineKind::Linear | TrendlineKind::Polynomial(_) => t.intercept,
|
||||
TrendlineKind::Exponential => t.intercept.filter(|b| *b > 0.0).map(f64::ln),
|
||||
_ => None,
|
||||
}
|
||||
.filter(|b| b.is_finite());
|
||||
let n = data.len() as f64;
|
||||
let center = if fixed.is_some() { 0.0 } else { data.iter().map(|p| p.0).sum::<f64>() / n };
|
||||
let scale = data.iter().map(|p| (p.0 - center).abs()).fold(0.0f64, f64::max);
|
||||
if !(scale.is_finite() && scale > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let first = usize::from(fixed.is_some());
|
||||
let b0 = fixed.unwrap_or(0.0);
|
||||
let m = d + 1 - first;
|
||||
let mut ata = vec![vec![0.0f64; m]; m];
|
||||
let mut aty = vec![0.0f64; m];
|
||||
for &(x, y) in &data {
|
||||
let u = (x - center) / scale;
|
||||
let pows: Vec<f64> = (first..=d).map(|k| u.powi(k as i32)).collect();
|
||||
for (i, pi) in pows.iter().enumerate() {
|
||||
aty[i] += pi * (y - b0);
|
||||
for (j, pj) in pows.iter().enumerate() {
|
||||
ata[i][j] += pi * pj;
|
||||
}
|
||||
}
|
||||
}
|
||||
let sol = solve(ata, aty)?;
|
||||
let mut a = vec![b0; d + 1];
|
||||
for (k, v) in sol.into_iter().enumerate() {
|
||||
a[k + first] = v;
|
||||
}
|
||||
let coef = expand(&a, center, scale)?;
|
||||
let fit = TrendFit { kind, coef, center, scale, a, r2: None };
|
||||
let mean = data.iter().map(|p| p.1).sum::<f64>() / n;
|
||||
let (mut ss_res, mut ss_tot) = (0.0f64, 0.0f64);
|
||||
for &(x, y) in &data {
|
||||
ss_res += (y - fit.poly(x)).powi(2);
|
||||
ss_tot += if fixed.is_some() { (y - b0).powi(2) } else { (y - mean).powi(2) };
|
||||
}
|
||||
let r2 = (ss_tot > 0.0 && ss_tot.is_finite() && ss_res.is_finite()).then(|| 1.0 - ss_res / ss_tot);
|
||||
Some(TrendFit { r2, ..fit })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_without_a_vec_per_point_is_bit_identical() {
|
||||
let mut r = Rng(0x1234_5678_9ABC_DEF1);
|
||||
let kinds = [
|
||||
TrendlineKind::Linear,
|
||||
TrendlineKind::Exponential,
|
||||
TrendlineKind::Logarithmic,
|
||||
TrendlineKind::Power,
|
||||
TrendlineKind::Polynomial(2),
|
||||
TrendlineKind::Polynomial(3),
|
||||
TrendlineKind::Polynomial(6),
|
||||
TrendlineKind::MovingAverage(3),
|
||||
];
|
||||
for case in 0..40 {
|
||||
let n = 2 + (r.next() % 60) as usize;
|
||||
let pts: Vec<(f64, f64)> = (0..n)
|
||||
.map(|i| {
|
||||
let x = i as f64 * (1.0 + r.unit()) + 1.0;
|
||||
let y = if case % 4 == 0 { f64::NAN } else { (r.unit() + 0.6) * 10f64.powi((case % 7) - 2) };
|
||||
(x, if case % 4 == 0 && i % 3 != 0 { r.unit() + 1.0 } else { y })
|
||||
})
|
||||
.collect();
|
||||
for kind in kinds {
|
||||
for intercept in [None, Some(0.5), Some(-1.0)] {
|
||||
let t = Trendline { kind, intercept, ..Trendline::default() };
|
||||
assert_eq!(fit_trendline(&t, &pts), fit_reference(&t, &pts), "case {case} {kind:?} {intercept:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,10 +147,10 @@ impl RangeRef {
|
||||
RangeRef::new(CellRef::new(0, 0), CellRef::new(MAX_ROWS - 1, MAX_COLS - 1))
|
||||
}
|
||||
pub fn height(&self) -> u32 {
|
||||
self.end.row - self.start.row + 1
|
||||
self.end.row.saturating_sub(self.start.row).saturating_add(1)
|
||||
}
|
||||
pub fn width(&self) -> u32 {
|
||||
self.end.col - self.start.col + 1
|
||||
self.end.col.saturating_sub(self.start.col).saturating_add(1)
|
||||
}
|
||||
pub fn count(&self) -> u64 {
|
||||
self.height() as u64 * self.width() as u64
|
||||
@@ -321,6 +321,13 @@ pub fn split_areas(reference: &str) -> Vec<&str> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hostile_range_dimensions_saturate_instead_of_wrapping() {
|
||||
let r = RangeRef { start: CellRef::new(0, 0), end: CellRef::new(u32::MAX, u32::MAX) };
|
||||
assert_eq!((r.height(), r.width()), (u32::MAX, u32::MAX));
|
||||
assert_eq!(r.count(), u64::from(u32::MAX) * u64::from(u32::MAX));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn areas_split_only_at_commas_outside_quoted_sheet_names() {
|
||||
assert_eq!(split_areas("B2:B4,D1"), ["B2:B4", "D1"]);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! GridCraft core types: cell addresses, values and Excel's coercion rules, error values,
|
||||
//! date serials and typed-input parsing. No dependencies on the rest of the workspace.
|
||||
//! date serials, regional formats and typed-input parsing. No dependencies on the rest of the workspace.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod addr;
|
||||
pub mod color;
|
||||
pub mod date;
|
||||
pub mod parse;
|
||||
pub mod region;
|
||||
pub mod value;
|
||||
|
||||
pub use addr::{
|
||||
@@ -14,4 +15,5 @@ pub use addr::{
|
||||
split_areas, split_sheet, unquote_sheet,
|
||||
};
|
||||
pub use date::DateSystem;
|
||||
pub use region::Region;
|
||||
pub use value::{Array, CellError, Value, compare, compare_numbers, compare_text, number_to_text, sort_compare};
|
||||
|
||||
@@ -0,0 +1,883 @@
|
||||
//! Regional formats: how a region writes numbers, dates, times and money.
|
||||
//!
|
||||
//! Excel takes these from the operating system's regional settings; they are independent of the
|
||||
//! interface language and of the formula language. A [`Region`] changes what people *type* into
|
||||
//! a cell (`1.234,56`, `25/09/2026`) and what cells *show* (separators, month and day names,
|
||||
//! AM/PM, the system short and long dates). It never changes what is stored: values are numbers,
|
||||
//! and the number format codes saved in a file stay in their canonical en-US spelling.
|
||||
//!
|
||||
//! [`EN_US`] is the default and reproduces GridCraft's original behaviour exactly.
|
||||
//!
|
||||
//! # Where the values come from
|
||||
//! The separators, month and weekday names, AM/PM words, currency symbols and the order of day,
|
||||
//! month and year are the Unicode CLDR conventions (`unicode-org/cldr-json`:
|
||||
//! `cldr-numbers-full/main/<locale>/numbers.json` and `cldr-dates-full/main/<locale>/ca-gregorian.json`),
|
||||
//! which is also what macOS, Android and ICU ship. They were read from those files for en-GB,
|
||||
//! en-IN, cs and ru, read from ICU's CLDR 48 data for the Swiss regions and nl-NL, and written
|
||||
//! from the same conventions for the other regions. CLDR data is under the Unicode License v3,
|
||||
//! reproduced in `NOTICE` (row in `ATTRIBUTION.md`).
|
||||
//! Deliberate choices where operating systems differ from CLDR:
|
||||
//! - Short dates use a four-digit year (`dd/mm/yyyy`, where CLDR has `dd/MM/y` or `dd/MM/yy`):
|
||||
//! Excel's short date is the system's, Windows writes four digits, and a two-digit year would
|
||||
//! not read back to the same date.
|
||||
//! - The long date is CLDR's *full* date (with the weekday), like Windows en-US, except where
|
||||
//! Windows' long date differs: Russian, Japanese and Chinese have no weekday (`23 августа
|
||||
//! 2005 г.`, `2005年8月23日`), as in Microsoft's National Language Support tables.
|
||||
//! - Korean writes the short date `yyyy-mm-dd` (CLDR: `yy. M. d.`), also as in Microsoft's
|
||||
//! National Language Support tables.
|
||||
//! - Month names for `mmm` are CLDR's abbreviated names (so en-GB has `Sept`), except
|
||||
//! Portuguese, which drops CLDR's period (`jan`, not `jan.`) as Excel in Brazil shows it.
|
||||
//! - French and Portuguese (Portugal) group digits with U+00A0 (Windows), not CLDR's U+202F.
|
||||
//! Parsing accepts every space-like grouping character in those regions.
|
||||
//! - The Swiss regions (en-CH, de-CH, fr-CH, it-CH; Liechtenstein uses de-CH) group digits with
|
||||
//! CLDR's ASCII apostrophe (`1'234.50`); parsing also accepts the typographic `’` that glibc
|
||||
//! uses. French (Switzerland) writes a decimal point like the other Swiss regions and glibc's
|
||||
//! `fr_CH`, where CLDR has a comma.
|
||||
//!
|
||||
//! Regions this module doesn't list (`de-AT`, `es-MX`, `en-CA`…) resolve to the main region of
|
||||
//! their language through [`lookup`]; their own conventions (a Mexican decimal point) are not
|
||||
//! modelled.
|
||||
|
||||
/// How digits are grouped.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Grouping {
|
||||
/// Groups of three (`1,234,567`).
|
||||
Thousands,
|
||||
/// The Indian system: three digits, then groups of two (`12,34,567`).
|
||||
Indian,
|
||||
}
|
||||
|
||||
/// The order of the parts of a short date.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DateOrder {
|
||||
Mdy,
|
||||
Dmy,
|
||||
Ymd,
|
||||
}
|
||||
|
||||
/// One region's conventions. See the module documentation.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Region {
|
||||
/// BCP 47 tag, `pt-BR`.
|
||||
pub tag: &'static str,
|
||||
/// The region's name in its own language.
|
||||
pub name: &'static str,
|
||||
/// Windows locale id, used by `[$-409]`-style format tags.
|
||||
pub lcid: u16,
|
||||
pub decimal: char,
|
||||
pub group: char,
|
||||
pub grouping: Grouping,
|
||||
pub date_order: DateOrder,
|
||||
pub date_sep: char,
|
||||
pub time_sep: char,
|
||||
/// The system short date as a canonical format code (`dd/mm/yyyy`): built-in format 14.
|
||||
pub short_date: &'static str,
|
||||
/// The system long date (`[$-F800]`).
|
||||
pub long_date: &'static str,
|
||||
/// The system short time. It contains `AM/PM` in regions that use a 12-hour clock.
|
||||
pub short_time: &'static str,
|
||||
/// The system long time (`[$-F400]`).
|
||||
pub long_time: &'static str,
|
||||
/// Built-in format 22: the short date and a 24-hour time.
|
||||
pub datetime: &'static str,
|
||||
pub months: [&'static str; 12],
|
||||
pub months_abbr: [&'static str; 12],
|
||||
/// The month names a date with a day uses (Russian `1 января`, Czech `1. ledna`), where they
|
||||
/// differ from `months`.
|
||||
pub months_genitive: Option<[&'static str; 12]>,
|
||||
/// Sunday first.
|
||||
pub weekdays: [&'static str; 7],
|
||||
pub weekdays_abbr: [&'static str; 7],
|
||||
pub am_pm: [&'static str; 2],
|
||||
pub currency: &'static str,
|
||||
/// The currency format with the currency's usual decimals (two, or none for the yen and the
|
||||
/// won), as a canonical code. Where it starts with a quote the symbol comes before the number,
|
||||
/// otherwise after.
|
||||
pub currency_format: &'static str,
|
||||
/// The same with no decimals (for a typed whole amount).
|
||||
pub currency_format_int: &'static str,
|
||||
}
|
||||
|
||||
const EN_MONTHS: [&str; 12] =
|
||||
["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||||
const EN_MONTHS_ABBR: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||
const EN_DAYS: [&str; 7] = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
const EN_DAYS_ABBR: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
const AM_PM: [&str; 2] = ["AM", "PM"];
|
||||
|
||||
/// English (United States): the default. Identical to the formats before regions existed.
|
||||
pub static EN_US: Region = Region {
|
||||
tag: "en-US",
|
||||
name: "English (United States)",
|
||||
lcid: 0x0409,
|
||||
decimal: '.',
|
||||
group: ',',
|
||||
grouping: Grouping::Thousands,
|
||||
date_order: DateOrder::Mdy,
|
||||
date_sep: '/',
|
||||
time_sep: ':',
|
||||
short_date: "m/d/yyyy",
|
||||
long_date: "dddd, mmmm dd, yyyy",
|
||||
short_time: "h:mm AM/PM",
|
||||
long_time: "h:mm:ss AM/PM",
|
||||
datetime: "m/d/yyyy h:mm",
|
||||
months: EN_MONTHS,
|
||||
months_abbr: EN_MONTHS_ABBR,
|
||||
months_genitive: None,
|
||||
weekdays: EN_DAYS,
|
||||
weekdays_abbr: EN_DAYS_ABBR,
|
||||
am_pm: AM_PM,
|
||||
currency: "$",
|
||||
currency_format: "\"$\"#,##0.00",
|
||||
currency_format_int: "\"$\"#,##0",
|
||||
};
|
||||
|
||||
static EN_GB: Region = Region {
|
||||
tag: "en-GB",
|
||||
name: "English (United Kingdom)",
|
||||
lcid: 0x0809,
|
||||
decimal: '.',
|
||||
group: ',',
|
||||
grouping: Grouping::Thousands,
|
||||
date_order: DateOrder::Dmy,
|
||||
date_sep: '/',
|
||||
time_sep: ':',
|
||||
short_date: "dd/mm/yyyy",
|
||||
long_date: "dddd, d mmmm yyyy",
|
||||
short_time: "hh:mm",
|
||||
long_time: "hh:mm:ss",
|
||||
datetime: "dd/mm/yyyy hh:mm",
|
||||
months: EN_MONTHS,
|
||||
months_abbr: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sept", "Oct", "Nov", "Dec"],
|
||||
months_genitive: None,
|
||||
weekdays: EN_DAYS,
|
||||
weekdays_abbr: EN_DAYS_ABBR,
|
||||
am_pm: AM_PM,
|
||||
currency: "£",
|
||||
currency_format: "\"£\"#,##0.00",
|
||||
currency_format_int: "\"£\"#,##0",
|
||||
};
|
||||
|
||||
static EN_IN: Region = Region {
|
||||
tag: "en-IN",
|
||||
name: "English (India)",
|
||||
lcid: 0x4009,
|
||||
decimal: '.',
|
||||
group: ',',
|
||||
grouping: Grouping::Indian,
|
||||
date_order: DateOrder::Dmy,
|
||||
date_sep: '/',
|
||||
time_sep: ':',
|
||||
short_date: "dd/mm/yyyy",
|
||||
long_date: "dddd, d mmmm yyyy",
|
||||
short_time: "h:mm AM/PM",
|
||||
long_time: "h:mm:ss AM/PM",
|
||||
datetime: "dd/mm/yyyy h:mm",
|
||||
months: EN_MONTHS,
|
||||
months_abbr: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sept", "Oct", "Nov", "Dec"],
|
||||
months_genitive: None,
|
||||
weekdays: EN_DAYS,
|
||||
weekdays_abbr: EN_DAYS_ABBR,
|
||||
am_pm: AM_PM,
|
||||
currency: "₹",
|
||||
currency_format: "\"₹\"#,##0.00",
|
||||
currency_format_int: "\"₹\"#,##0",
|
||||
};
|
||||
|
||||
static EN_CH: Region = Region {
|
||||
tag: "en-CH",
|
||||
name: "English (Switzerland)",
|
||||
// English (Switzerland) has no LCID of its own: Windows reports LOCALE_CUSTOM_UNSPECIFIED.
|
||||
lcid: 0x1000,
|
||||
decimal: '.',
|
||||
group: '\'',
|
||||
grouping: Grouping::Thousands,
|
||||
date_order: DateOrder::Dmy,
|
||||
date_sep: '.',
|
||||
time_sep: ':',
|
||||
short_date: "dd.mm.yyyy",
|
||||
long_date: "dddd, d mmmm yyyy",
|
||||
short_time: "hh:mm",
|
||||
long_time: "hh:mm:ss",
|
||||
datetime: "dd.mm.yyyy hh:mm",
|
||||
months: EN_MONTHS,
|
||||
months_abbr: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sept", "Oct", "Nov", "Dec"],
|
||||
months_genitive: None,
|
||||
weekdays: EN_DAYS,
|
||||
weekdays_abbr: EN_DAYS_ABBR,
|
||||
am_pm: AM_PM,
|
||||
currency: "CHF",
|
||||
currency_format: "\"CHF\" #,##0.00",
|
||||
currency_format_int: "\"CHF\" #,##0",
|
||||
};
|
||||
|
||||
static ES_ES: Region = Region {
|
||||
tag: "es-ES",
|
||||
name: "Español (España)",
|
||||
lcid: 0x0C0A,
|
||||
decimal: ',',
|
||||
group: '.',
|
||||
grouping: Grouping::Thousands,
|
||||
date_order: DateOrder::Dmy,
|
||||
date_sep: '/',
|
||||
time_sep: ':',
|
||||
short_date: "dd/mm/yyyy",
|
||||
long_date: "dddd, d \"de\" mmmm \"de\" yyyy",
|
||||
short_time: "h:mm",
|
||||
long_time: "h:mm:ss",
|
||||
datetime: "dd/mm/yyyy h:mm",
|
||||
months: ["enero", "febrero", "marzo", "abril", "mayo", "junio", "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"],
|
||||
months_abbr: ["ene", "feb", "mar", "abr", "may", "jun", "jul", "ago", "sep", "oct", "nov", "dic"],
|
||||
months_genitive: None,
|
||||
weekdays: ["domingo", "lunes", "martes", "miércoles", "jueves", "viernes", "sábado"],
|
||||
weekdays_abbr: ["dom", "lun", "mar", "mié", "jue", "vie", "sáb"],
|
||||
am_pm: ["a. m.", "p. m."],
|
||||
currency: "€",
|
||||
currency_format: "#,##0.00 \"€\"",
|
||||
currency_format_int: "#,##0 \"€\"",
|
||||
};
|
||||
|
||||
static DE_DE: Region = Region {
|
||||
tag: "de-DE",
|
||||
name: "Deutsch (Deutschland)",
|
||||
lcid: 0x0407,
|
||||
decimal: ',',
|
||||
group: '.',
|
||||
grouping: Grouping::Thousands,
|
||||
date_order: DateOrder::Dmy,
|
||||
date_sep: '.',
|
||||
time_sep: ':',
|
||||
short_date: "dd.mm.yyyy",
|
||||
long_date: "dddd, d. mmmm yyyy",
|
||||
short_time: "hh:mm",
|
||||
long_time: "hh:mm:ss",
|
||||
datetime: "dd.mm.yyyy hh:mm",
|
||||
months: ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"],
|
||||
months_abbr: ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"],
|
||||
months_genitive: None,
|
||||
weekdays: ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"],
|
||||
weekdays_abbr: ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"],
|
||||
am_pm: AM_PM,
|
||||
currency: "€",
|
||||
currency_format: "#,##0.00 \"€\"",
|
||||
currency_format_int: "#,##0 \"€\"",
|
||||
};
|
||||
|
||||
static DE_CH: Region = Region {
|
||||
tag: "de-CH",
|
||||
name: "Deutsch (Schweiz)",
|
||||
lcid: 0x0807,
|
||||
decimal: '.',
|
||||
group: '\'',
|
||||
grouping: Grouping::Thousands,
|
||||
date_order: DateOrder::Dmy,
|
||||
date_sep: '.',
|
||||
time_sep: ':',
|
||||
short_date: "dd.mm.yyyy",
|
||||
long_date: "dddd, d. mmmm yyyy",
|
||||
short_time: "hh:mm",
|
||||
long_time: "hh:mm:ss",
|
||||
datetime: "dd.mm.yyyy hh:mm",
|
||||
months: ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"],
|
||||
months_abbr: ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"],
|
||||
months_genitive: None,
|
||||
weekdays: ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"],
|
||||
weekdays_abbr: ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"],
|
||||
am_pm: AM_PM,
|
||||
currency: "CHF",
|
||||
currency_format: "\"CHF\" #,##0.00",
|
||||
currency_format_int: "\"CHF\" #,##0",
|
||||
};
|
||||
|
||||
static FR_FR: Region = Region {
|
||||
tag: "fr-FR",
|
||||
name: "Français (France)",
|
||||
lcid: 0x040C,
|
||||
decimal: ',',
|
||||
group: '\u{a0}',
|
||||
grouping: Grouping::Thousands,
|
||||
date_order: DateOrder::Dmy,
|
||||
date_sep: '/',
|
||||
time_sep: ':',
|
||||
short_date: "dd/mm/yyyy",
|
||||
long_date: "dddd d mmmm yyyy",
|
||||
short_time: "hh:mm",
|
||||
long_time: "hh:mm:ss",
|
||||
datetime: "dd/mm/yyyy hh:mm",
|
||||
months: ["janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre", "octobre", "novembre", "décembre"],
|
||||
months_abbr: ["janv.", "févr.", "mars", "avr.", "mai", "juin", "juil.", "août", "sept.", "oct.", "nov.", "déc."],
|
||||
months_genitive: None,
|
||||
weekdays: ["dimanche", "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi"],
|
||||
weekdays_abbr: ["dim.", "lun.", "mar.", "mer.", "jeu.", "ven.", "sam."],
|
||||
am_pm: AM_PM,
|
||||
currency: "€",
|
||||
currency_format: "#,##0.00 \"€\"",
|
||||
currency_format_int: "#,##0 \"€\"",
|
||||
};
|
||||
|
||||
static FR_CH: Region = Region {
|
||||
tag: "fr-CH",
|
||||
name: "Français (Suisse)",
|
||||
lcid: 0x100C,
|
||||
decimal: '.',
|
||||
group: '\'',
|
||||
grouping: Grouping::Thousands,
|
||||
date_order: DateOrder::Dmy,
|
||||
date_sep: '.',
|
||||
time_sep: ':',
|
||||
short_date: "dd.mm.yyyy",
|
||||
long_date: "dddd, d mmmm yyyy",
|
||||
short_time: "hh:mm",
|
||||
long_time: "hh:mm:ss",
|
||||
datetime: "dd.mm.yyyy hh:mm",
|
||||
months: ["janvier", "février", "mars", "avril", "mai", "juin", "juillet", "août", "septembre", "octobre", "novembre", "décembre"],
|
||||
months_abbr: ["janv.", "févr.", "mars", "avr.", "mai", "juin", "juil.", "août", "sept.", "oct.", "nov.", "déc."],
|
||||
months_genitive: None,
|
||||
weekdays: ["dimanche", "lundi", "mardi", "mercredi", "jeudi", "vendredi", "samedi"],
|
||||
weekdays_abbr: ["dim.", "lun.", "mar.", "mer.", "jeu.", "ven.", "sam."],
|
||||
am_pm: AM_PM,
|
||||
currency: "CHF",
|
||||
currency_format: "#,##0.00 \"CHF\"",
|
||||
currency_format_int: "#,##0 \"CHF\"",
|
||||
};
|
||||
|
||||
static IT_IT: Region = Region {
|
||||
tag: "it-IT",
|
||||
name: "Italiano (Italia)",
|
||||
lcid: 0x0410,
|
||||
decimal: ',',
|
||||
group: '.',
|
||||
grouping: Grouping::Thousands,
|
||||
date_order: DateOrder::Dmy,
|
||||
date_sep: '/',
|
||||
time_sep: ':',
|
||||
short_date: "dd/mm/yyyy",
|
||||
long_date: "dddd d mmmm yyyy",
|
||||
short_time: "hh:mm",
|
||||
long_time: "hh:mm:ss",
|
||||
datetime: "dd/mm/yyyy hh:mm",
|
||||
months: ["gennaio", "febbraio", "marzo", "aprile", "maggio", "giugno", "luglio", "agosto", "settembre", "ottobre", "novembre", "dicembre"],
|
||||
months_abbr: ["gen", "feb", "mar", "apr", "mag", "giu", "lug", "ago", "set", "ott", "nov", "dic"],
|
||||
months_genitive: None,
|
||||
weekdays: ["domenica", "lunedì", "martedì", "mercoledì", "giovedì", "venerdì", "sabato"],
|
||||
weekdays_abbr: ["dom", "lun", "mar", "mer", "gio", "ven", "sab"],
|
||||
am_pm: AM_PM,
|
||||
currency: "€",
|
||||
currency_format: "#,##0.00 \"€\"",
|
||||
currency_format_int: "#,##0 \"€\"",
|
||||
};
|
||||
|
||||
static IT_CH: Region = Region {
|
||||
tag: "it-CH",
|
||||
name: "Italiano (Svizzera)",
|
||||
lcid: 0x0810,
|
||||
decimal: '.',
|
||||
group: '\'',
|
||||
grouping: Grouping::Thousands,
|
||||
date_order: DateOrder::Dmy,
|
||||
date_sep: '.',
|
||||
time_sep: ':',
|
||||
short_date: "dd.mm.yyyy",
|
||||
long_date: "dddd, d mmmm yyyy",
|
||||
short_time: "hh:mm",
|
||||
long_time: "hh:mm:ss",
|
||||
datetime: "dd.mm.yyyy hh:mm",
|
||||
months: ["gennaio", "febbraio", "marzo", "aprile", "maggio", "giugno", "luglio", "agosto", "settembre", "ottobre", "novembre", "dicembre"],
|
||||
months_abbr: ["gen", "feb", "mar", "apr", "mag", "giu", "lug", "ago", "set", "ott", "nov", "dic"],
|
||||
months_genitive: None,
|
||||
weekdays: ["domenica", "lunedì", "martedì", "mercoledì", "giovedì", "venerdì", "sabato"],
|
||||
weekdays_abbr: ["dom", "lun", "mar", "mer", "gio", "ven", "sab"],
|
||||
am_pm: AM_PM,
|
||||
currency: "CHF",
|
||||
currency_format: "\"CHF\" #,##0.00",
|
||||
currency_format_int: "\"CHF\" #,##0",
|
||||
};
|
||||
|
||||
static NL_NL: Region = Region {
|
||||
tag: "nl-NL",
|
||||
name: "Nederlands (Nederland)",
|
||||
lcid: 0x0413,
|
||||
decimal: ',',
|
||||
group: '.',
|
||||
grouping: Grouping::Thousands,
|
||||
date_order: DateOrder::Dmy,
|
||||
date_sep: '-',
|
||||
time_sep: ':',
|
||||
short_date: "dd-mm-yyyy",
|
||||
long_date: "dddd d mmmm yyyy",
|
||||
short_time: "hh:mm",
|
||||
long_time: "hh:mm:ss",
|
||||
datetime: "dd-mm-yyyy hh:mm",
|
||||
months: ["januari", "februari", "maart", "april", "mei", "juni", "juli", "augustus", "september", "oktober", "november", "december"],
|
||||
months_abbr: ["jan", "feb", "mrt", "apr", "mei", "jun", "jul", "aug", "sep", "okt", "nov", "dec"],
|
||||
months_genitive: None,
|
||||
weekdays: ["zondag", "maandag", "dinsdag", "woensdag", "donderdag", "vrijdag", "zaterdag"],
|
||||
weekdays_abbr: ["zo", "ma", "di", "wo", "do", "vr", "za"],
|
||||
am_pm: AM_PM,
|
||||
currency: "€",
|
||||
currency_format: "\"€\" #,##0.00",
|
||||
currency_format_int: "\"€\" #,##0",
|
||||
};
|
||||
|
||||
static PT_BR: Region = Region {
|
||||
tag: "pt-BR",
|
||||
name: "Português (Brasil)",
|
||||
lcid: 0x0416,
|
||||
decimal: ',',
|
||||
group: '.',
|
||||
grouping: Grouping::Thousands,
|
||||
date_order: DateOrder::Dmy,
|
||||
date_sep: '/',
|
||||
time_sep: ':',
|
||||
short_date: "dd/mm/yyyy",
|
||||
long_date: "dddd, d \"de\" mmmm \"de\" yyyy",
|
||||
short_time: "hh:mm",
|
||||
long_time: "hh:mm:ss",
|
||||
datetime: "dd/mm/yyyy hh:mm",
|
||||
months: ["janeiro", "fevereiro", "março", "abril", "maio", "junho", "julho", "agosto", "setembro", "outubro", "novembro", "dezembro"],
|
||||
months_abbr: ["jan", "fev", "mar", "abr", "mai", "jun", "jul", "ago", "set", "out", "nov", "dez"],
|
||||
months_genitive: None,
|
||||
weekdays: ["domingo", "segunda-feira", "terça-feira", "quarta-feira", "quinta-feira", "sexta-feira", "sábado"],
|
||||
weekdays_abbr: ["dom", "seg", "ter", "qua", "qui", "sex", "sáb"],
|
||||
am_pm: AM_PM,
|
||||
currency: "R$",
|
||||
currency_format: "\"R$\" #,##0.00",
|
||||
currency_format_int: "\"R$\" #,##0",
|
||||
};
|
||||
|
||||
static PT_PT: Region = Region {
|
||||
tag: "pt-PT",
|
||||
name: "Português (Portugal)",
|
||||
lcid: 0x0816,
|
||||
decimal: ',',
|
||||
group: '\u{a0}',
|
||||
grouping: Grouping::Thousands,
|
||||
date_order: DateOrder::Dmy,
|
||||
date_sep: '/',
|
||||
time_sep: ':',
|
||||
short_date: "dd/mm/yyyy",
|
||||
long_date: "dddd, d \"de\" mmmm \"de\" yyyy",
|
||||
short_time: "hh:mm",
|
||||
long_time: "hh:mm:ss",
|
||||
datetime: "dd/mm/yyyy hh:mm",
|
||||
months: ["janeiro", "fevereiro", "março", "abril", "maio", "junho", "julho", "agosto", "setembro", "outubro", "novembro", "dezembro"],
|
||||
months_abbr: ["jan", "fev", "mar", "abr", "mai", "jun", "jul", "ago", "set", "out", "nov", "dez"],
|
||||
months_genitive: None,
|
||||
weekdays: ["domingo", "segunda-feira", "terça-feira", "quarta-feira", "quinta-feira", "sexta-feira", "sábado"],
|
||||
weekdays_abbr: ["dom", "seg", "ter", "qua", "qui", "sex", "sáb"],
|
||||
am_pm: AM_PM,
|
||||
currency: "€",
|
||||
currency_format: "#,##0.00 \"€\"",
|
||||
currency_format_int: "#,##0 \"€\"",
|
||||
};
|
||||
|
||||
static RU_RU: Region = Region {
|
||||
tag: "ru-RU",
|
||||
name: "Русский (Россия)",
|
||||
lcid: 0x0419,
|
||||
decimal: ',',
|
||||
group: '\u{a0}',
|
||||
grouping: Grouping::Thousands,
|
||||
date_order: DateOrder::Dmy,
|
||||
date_sep: '.',
|
||||
time_sep: ':',
|
||||
short_date: "dd.mm.yyyy",
|
||||
long_date: "d mmmm yyyy\" г.\"",
|
||||
short_time: "hh:mm",
|
||||
long_time: "hh:mm:ss",
|
||||
datetime: "dd.mm.yyyy hh:mm",
|
||||
months: ["январь", "февраль", "март", "апрель", "май", "июнь", "июль", "август", "сентябрь", "октябрь", "ноябрь", "декабрь"],
|
||||
months_abbr: ["янв.", "февр.", "март", "апр.", "май", "июнь", "июль", "авг.", "сент.", "окт.", "нояб.", "дек."],
|
||||
months_genitive: Some(["января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря"]),
|
||||
weekdays: ["воскресенье", "понедельник", "вторник", "среда", "четверг", "пятница", "суббота"],
|
||||
weekdays_abbr: ["вс", "пн", "вт", "ср", "чт", "пт", "сб"],
|
||||
am_pm: AM_PM,
|
||||
currency: "₽",
|
||||
currency_format: "#,##0.00\u{a0}\"₽\"",
|
||||
currency_format_int: "#,##0\u{a0}\"₽\"",
|
||||
};
|
||||
|
||||
static CS_CZ: Region = Region {
|
||||
tag: "cs-CZ",
|
||||
name: "Čeština (Česko)",
|
||||
lcid: 0x0405,
|
||||
decimal: ',',
|
||||
group: '\u{a0}',
|
||||
grouping: Grouping::Thousands,
|
||||
date_order: DateOrder::Dmy,
|
||||
date_sep: '.',
|
||||
time_sep: ':',
|
||||
short_date: "dd.mm.yyyy",
|
||||
long_date: "dddd d. mmmm yyyy",
|
||||
short_time: "h:mm",
|
||||
long_time: "h:mm:ss",
|
||||
datetime: "dd.mm.yyyy h:mm",
|
||||
months: ["leden", "únor", "březen", "duben", "květen", "červen", "červenec", "srpen", "září", "říjen", "listopad", "prosinec"],
|
||||
months_abbr: ["led", "úno", "bře", "dub", "kvě", "čvn", "čvc", "srp", "zář", "říj", "lis", "pro"],
|
||||
months_genitive: Some(["ledna", "února", "března", "dubna", "května", "června", "července", "srpna", "září", "října", "listopadu", "prosince"]),
|
||||
weekdays: ["neděle", "pondělí", "úterý", "středa", "čtvrtek", "pátek", "sobota"],
|
||||
weekdays_abbr: ["ne", "po", "út", "st", "čt", "pá", "so"],
|
||||
am_pm: ["dop.", "odp."],
|
||||
currency: "Kč",
|
||||
currency_format: "#,##0.00 \"Kč\"",
|
||||
currency_format_int: "#,##0 \"Kč\"",
|
||||
};
|
||||
|
||||
static JA_JP: Region = Region {
|
||||
tag: "ja-JP",
|
||||
name: "日本語 (日本)",
|
||||
lcid: 0x0411,
|
||||
decimal: '.',
|
||||
group: ',',
|
||||
grouping: Grouping::Thousands,
|
||||
date_order: DateOrder::Ymd,
|
||||
date_sep: '/',
|
||||
time_sep: ':',
|
||||
short_date: "yyyy/m/d",
|
||||
long_date: "yyyy\"年\"m\"月\"d\"日\"",
|
||||
short_time: "h:mm",
|
||||
long_time: "h:mm:ss",
|
||||
datetime: "yyyy/m/d h:mm",
|
||||
months: ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"],
|
||||
months_abbr: ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"],
|
||||
months_genitive: None,
|
||||
weekdays: ["日曜日", "月曜日", "火曜日", "水曜日", "木曜日", "金曜日", "土曜日"],
|
||||
weekdays_abbr: ["日", "月", "火", "水", "木", "金", "土"],
|
||||
am_pm: ["午前", "午後"],
|
||||
currency: "¥",
|
||||
currency_format: "\"¥\"#,##0",
|
||||
currency_format_int: "\"¥\"#,##0",
|
||||
};
|
||||
|
||||
static KO_KR: Region = Region {
|
||||
tag: "ko-KR",
|
||||
name: "한국어 (대한민국)",
|
||||
lcid: 0x0412,
|
||||
decimal: '.',
|
||||
group: ',',
|
||||
grouping: Grouping::Thousands,
|
||||
date_order: DateOrder::Ymd,
|
||||
date_sep: '-',
|
||||
time_sep: ':',
|
||||
short_date: "yyyy-mm-dd",
|
||||
long_date: "yyyy\"년\" m\"월\" d\"일\" dddd",
|
||||
short_time: "AM/PM h:mm",
|
||||
long_time: "AM/PM h:mm:ss",
|
||||
datetime: "yyyy-mm-dd hh:mm",
|
||||
months: ["1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월", "10월", "11월", "12월"],
|
||||
months_abbr: ["1월", "2월", "3월", "4월", "5월", "6월", "7월", "8월", "9월", "10월", "11월", "12월"],
|
||||
months_genitive: None,
|
||||
weekdays: ["일요일", "월요일", "화요일", "수요일", "목요일", "금요일", "토요일"],
|
||||
weekdays_abbr: ["일", "월", "화", "수", "목", "금", "토"],
|
||||
am_pm: ["오전", "오후"],
|
||||
currency: "₩",
|
||||
currency_format: "\"₩\"#,##0",
|
||||
currency_format_int: "\"₩\"#,##0",
|
||||
};
|
||||
|
||||
static ZH_CN: Region = Region {
|
||||
tag: "zh-CN",
|
||||
name: "中文 (中国)",
|
||||
lcid: 0x0804,
|
||||
decimal: '.',
|
||||
group: ',',
|
||||
grouping: Grouping::Thousands,
|
||||
date_order: DateOrder::Ymd,
|
||||
date_sep: '/',
|
||||
time_sep: ':',
|
||||
short_date: "yyyy/m/d",
|
||||
long_date: "yyyy\"年\"m\"月\"d\"日\"",
|
||||
short_time: "h:mm",
|
||||
long_time: "h:mm:ss",
|
||||
datetime: "yyyy/m/d h:mm",
|
||||
months: ["一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月"],
|
||||
months_abbr: ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"],
|
||||
months_genitive: None,
|
||||
weekdays: ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"],
|
||||
weekdays_abbr: ["周日", "周一", "周二", "周三", "周四", "周五", "周六"],
|
||||
am_pm: ["上午", "下午"],
|
||||
currency: "¥",
|
||||
currency_format: "\"¥\"#,##0.00",
|
||||
currency_format_int: "\"¥\"#,##0",
|
||||
};
|
||||
|
||||
static ZH_TW: Region = Region {
|
||||
tag: "zh-TW",
|
||||
name: "中文 (台灣)",
|
||||
lcid: 0x0404,
|
||||
decimal: '.',
|
||||
group: ',',
|
||||
grouping: Grouping::Thousands,
|
||||
date_order: DateOrder::Ymd,
|
||||
date_sep: '/',
|
||||
time_sep: ':',
|
||||
short_date: "yyyy/m/d",
|
||||
long_date: "yyyy\"年\"m\"月\"d\"日\"",
|
||||
short_time: "AM/PM hh:mm",
|
||||
long_time: "AM/PM hh:mm:ss",
|
||||
datetime: "yyyy/m/d hh:mm",
|
||||
months: ["一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月"],
|
||||
months_abbr: ["1月", "2月", "3月", "4月", "5月", "6月", "7月", "8月", "9月", "10月", "11月", "12月"],
|
||||
months_genitive: None,
|
||||
weekdays: ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"],
|
||||
weekdays_abbr: ["週日", "週一", "週二", "週三", "週四", "週五", "週六"],
|
||||
am_pm: ["上午", "下午"],
|
||||
currency: "NT$",
|
||||
currency_format: "\"NT$\"#,##0.00",
|
||||
currency_format_int: "\"NT$\"#,##0",
|
||||
};
|
||||
|
||||
static ALL: [&Region; 20] = [
|
||||
&EN_US, &EN_GB, &EN_IN, &EN_CH, &ES_ES, &DE_DE, &DE_CH, &FR_FR, &FR_CH, &IT_IT, &IT_CH, &NL_NL, &PT_BR, &PT_PT, &RU_RU, &CS_CZ, &JA_JP, &KO_KR,
|
||||
&ZH_CN, &ZH_TW,
|
||||
];
|
||||
|
||||
/// Every region, English (United States) first.
|
||||
pub fn all() -> &'static [&'static Region] {
|
||||
&ALL
|
||||
}
|
||||
|
||||
/// The default region, English (United States).
|
||||
pub fn default_region() -> &'static Region {
|
||||
&EN_US
|
||||
}
|
||||
|
||||
/// The region with exactly this tag (`pt-BR`; case and `_` don't matter).
|
||||
pub fn find(tag: &str) -> Option<&'static Region> {
|
||||
let t = tag.trim().replace('_', "-");
|
||||
ALL.iter().copied().find(|r| r.tag.eq_ignore_ascii_case(&t))
|
||||
}
|
||||
|
||||
/// The region for a tag as operating systems and browsers write it: `pt-BR`, `pt_BR.UTF-8`,
|
||||
/// `de`, `en-AU`, `zh-Hant`, `sr_RS@latin`. An exact match wins; otherwise the language's main
|
||||
/// region (with the usual variants: `en-AU`→`en-GB`, `pt-AO`→`pt-PT`, `zh-Hant`→`zh-TW`).
|
||||
/// An unknown language is English (United States).
|
||||
pub fn lookup(tag: &str) -> &'static Region {
|
||||
if let Some(r) = find(tag) {
|
||||
return r;
|
||||
}
|
||||
let base = tag.split(['.', '@']).next().unwrap_or("");
|
||||
let norm = base.trim().replace('_', "-");
|
||||
if let Some(r) = find(&norm) {
|
||||
return r;
|
||||
}
|
||||
let mut parts = norm.split('-').filter(|p| !p.is_empty());
|
||||
let lang = parts.next().unwrap_or("").to_ascii_lowercase();
|
||||
let mut script = String::new();
|
||||
let mut country = String::new();
|
||||
for p in parts {
|
||||
// An extension or private-use singleton (`-u-ca-gregory`, `-x-…`) ends the region part.
|
||||
if p.len() == 1 {
|
||||
break;
|
||||
}
|
||||
if p.len() == 4 && p.chars().all(|c| c.is_ascii_alphabetic()) {
|
||||
script = p.to_ascii_lowercase();
|
||||
} else if (p.len() == 2 && p.chars().all(|c| c.is_ascii_alphabetic())) || (p.len() == 3 && p.chars().all(|c| c.is_ascii_digit())) {
|
||||
country = p.to_ascii_uppercase();
|
||||
}
|
||||
}
|
||||
let tag = match lang.as_str() {
|
||||
"en" => match country.as_str() {
|
||||
"GB" | "AU" | "NZ" | "IE" => "en-GB",
|
||||
"IN" => "en-IN",
|
||||
"CH" | "LI" => "en-CH",
|
||||
_ => "en-US",
|
||||
},
|
||||
"pt" => match country.as_str() {
|
||||
"AO" | "CH" | "CV" | "FR" | "GQ" | "GW" | "LU" | "MO" | "MZ" | "ST" | "TL" | "PT" => "pt-PT",
|
||||
_ => "pt-BR",
|
||||
},
|
||||
"zh" => match country.as_str() {
|
||||
// An explicit territory wins over the script (`zh-Hant-CN` is China's conventions).
|
||||
"CN" | "SG" => "zh-CN",
|
||||
"TW" | "HK" | "MO" => "zh-TW",
|
||||
_ if script == "hant" => "zh-TW",
|
||||
_ => "zh-CN",
|
||||
},
|
||||
"es" => "es-ES",
|
||||
"de" => match country.as_str() {
|
||||
"CH" | "LI" => "de-CH",
|
||||
_ => "de-DE",
|
||||
},
|
||||
"fr" => match country.as_str() {
|
||||
"CH" | "LI" => "fr-CH",
|
||||
_ => "fr-FR",
|
||||
},
|
||||
"it" => match country.as_str() {
|
||||
"CH" | "LI" => "it-CH",
|
||||
_ => "it-IT",
|
||||
},
|
||||
"nl" => "nl-NL",
|
||||
"ru" => "ru-RU",
|
||||
"cs" => "cs-CZ",
|
||||
"ja" => "ja-JP",
|
||||
"ko" => "ko-KR",
|
||||
_ => "en-US",
|
||||
};
|
||||
find(tag).unwrap_or(&EN_US)
|
||||
}
|
||||
|
||||
impl Region {
|
||||
/// True for the characters that separate groups of digits in this region. A region that
|
||||
/// groups with a space also accepts the other space-like characters (NBSP, narrow NBSP, thin
|
||||
/// space), as people type and paste any of them; one that groups with an apostrophe accepts
|
||||
/// both `'` and `’`.
|
||||
pub fn is_group(&self, c: char) -> bool {
|
||||
c == self.group || (is_space_like(self.group) && is_space_like(c)) || (is_apostrophe_like(self.group) && is_apostrophe_like(c))
|
||||
}
|
||||
|
||||
/// True when the currency symbol comes before the number.
|
||||
pub fn currency_first(&self) -> bool {
|
||||
self.currency_format.starts_with('"')
|
||||
}
|
||||
|
||||
/// Month number (1–12) for a month name as typed: the full name, the abbreviation (with or
|
||||
/// without its period) or any prefix of the full name of three or more letters (`Sept`),
|
||||
/// ignoring case. `word` is one word: the genitive form used after a day (`января` in
|
||||
/// `1 января`) matches too, and the caller reads the day itself.
|
||||
pub fn month_from_name(&self, word: &str) -> Option<u32> {
|
||||
let w = word.trim().to_lowercase();
|
||||
// Where the abbreviations end in a period (`сент.`, `janv.`), any abbreviation may be typed
|
||||
// with one (`мар.`, `juin.`). Elsewhere a period is not part of a month name, so en-US
|
||||
// `Mar. 4, 2026` stays text as before.
|
||||
let dotted = self.months_abbr.iter().any(|a| a.ends_with('.'));
|
||||
let stem = if dotted { w.trim_end_matches('.') } else { w.as_str() };
|
||||
if stem.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let long_enough = stem.chars().count() >= 3;
|
||||
for (i, full) in self.months.iter().enumerate() {
|
||||
let full_l = full.to_lowercase();
|
||||
let abbr_l = self.months_abbr.get(i).map(|a| a.to_lowercase()).unwrap_or_default();
|
||||
let gen_l = self.months_genitive.as_ref().and_then(|g| g.get(i)).map(|g| g.to_lowercase());
|
||||
let hit = w == full_l
|
||||
|| w == abbr_l
|
||||
|| stem == abbr_l.trim_end_matches('.')
|
||||
|| gen_l.as_deref() == Some(w.as_str())
|
||||
|| (long_enough && full_l.starts_with(stem))
|
||||
|| (long_enough && gen_l.as_deref().is_some_and(|g| g.starts_with(stem)));
|
||||
if hit {
|
||||
return Some(i as u32 + 1);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The name of month `month` (1–12) for `mmmm`; `with_day` selects the genitive form where
|
||||
/// the region has one.
|
||||
pub fn month_name(&self, month: u32, with_day: bool) -> &'static str {
|
||||
let i = (month as usize).wrapping_sub(1);
|
||||
if with_day && let Some(g) = &self.months_genitive {
|
||||
return g.get(i).copied().unwrap_or("");
|
||||
}
|
||||
self.months.get(i).copied().unwrap_or("")
|
||||
}
|
||||
|
||||
/// True when the short time uses a 12-hour clock.
|
||||
pub fn is_12_hour(&self) -> bool {
|
||||
self.short_time.contains("AM/PM")
|
||||
}
|
||||
}
|
||||
|
||||
fn is_space_like(c: char) -> bool {
|
||||
matches!(c, ' ' | '\u{a0}' | '\u{202f}' | '\u{2009}')
|
||||
}
|
||||
|
||||
fn is_apostrophe_like(c: char) -> bool {
|
||||
matches!(c, '\'' | '\u{2019}')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lookup_accepts_system_and_browser_tags() {
|
||||
let cases = [
|
||||
("pt-BR", "pt-BR"),
|
||||
("pt_BR.UTF-8", "pt-BR"),
|
||||
("pt", "pt-BR"),
|
||||
("pt-PT", "pt-PT"),
|
||||
("pt-AO", "pt-PT"),
|
||||
("de", "de-DE"),
|
||||
("de-AT", "de-DE"),
|
||||
("de_CH.UTF-8", "de-CH"),
|
||||
("de-LI", "de-CH"),
|
||||
("fr-CH", "fr-CH"),
|
||||
("fr-LI", "fr-CH"),
|
||||
("fr-BE", "fr-FR"),
|
||||
("it_CH.UTF-8", "it-CH"),
|
||||
("it-LI", "it-CH"),
|
||||
("en-CH", "en-CH"),
|
||||
("nl_NL.UTF-8", "nl-NL"),
|
||||
("nl", "nl-NL"),
|
||||
("en-AU", "en-GB"),
|
||||
("en_GB.UTF-8", "en-GB"),
|
||||
("en-IN", "en-IN"),
|
||||
("en-CA", "en-US"),
|
||||
("zh-Hant", "zh-TW"),
|
||||
("zh-HK", "zh-TW"),
|
||||
("zh", "zh-CN"),
|
||||
// Extensions don't replace the region or script; an explicit territory beats the script.
|
||||
("en-GB-u-ca-gregory", "en-GB"),
|
||||
("zh-Hant-u-nu-latn", "zh-TW"),
|
||||
("zh-Hant-CN", "zh-CN"),
|
||||
("zh-Hans-TW", "zh-TW"),
|
||||
("cs_CZ.UTF-8", "cs-CZ"),
|
||||
("C", "en-US"),
|
||||
("", "en-US"),
|
||||
("xx-YY", "en-US"),
|
||||
];
|
||||
for (tag, expected) in cases {
|
||||
assert_eq!(lookup(tag).tag, expected, "{tag}");
|
||||
}
|
||||
assert_eq!(find("DE-de").map(|r| r.tag), Some("de-DE"));
|
||||
assert!(find("xx").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_region_is_consistent() {
|
||||
let mut tags = std::collections::HashSet::new();
|
||||
for r in all() {
|
||||
assert!(tags.insert(r.tag), "duplicate {}", r.tag);
|
||||
assert_ne!(r.decimal, r.group, "{}", r.tag);
|
||||
assert!(r.currency_format.contains(r.currency), "{}", r.tag);
|
||||
assert!(r.months.iter().all(|m| !m.is_empty()) && r.months_abbr.iter().all(|m| !m.is_empty()), "{}", r.tag);
|
||||
assert!(r.weekdays.iter().all(|m| !m.is_empty()) && r.weekdays_abbr.iter().all(|m| !m.is_empty()), "{}", r.tag);
|
||||
assert_eq!(find(r.tag).map(|x| x.tag), Some(r.tag));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn month_names_match_loosely() {
|
||||
let de = find("de-DE").unwrap();
|
||||
assert_eq!(de.month_from_name("März"), Some(3));
|
||||
assert_eq!(de.month_from_name("mär"), Some(3));
|
||||
assert_eq!(de.month_from_name("DEZ"), Some(12));
|
||||
assert_eq!(de.month_from_name("de"), None);
|
||||
let gb = find("en-GB").unwrap();
|
||||
assert_eq!(gb.month_from_name("Sept"), Some(9));
|
||||
assert_eq!(gb.month_from_name("sep"), Some(9));
|
||||
let ru = find("ru-RU").unwrap();
|
||||
assert_eq!(ru.month_from_name("сентября"), Some(9));
|
||||
assert_eq!(ru.month_from_name("сент."), Some(9));
|
||||
// Abbreviations typed with a period the table doesn't carry.
|
||||
assert_eq!(ru.month_from_name("мар."), Some(3));
|
||||
assert_eq!(ru.month_from_name("июн."), Some(6));
|
||||
assert_eq!(find("fr-FR").unwrap().month_from_name("juin."), Some(6));
|
||||
assert_eq!(EN_US.month_from_name("Mar."), None);
|
||||
assert_eq!(de.month_from_name("."), None);
|
||||
assert_eq!(EN_US.month_from_name("Ma"), None);
|
||||
assert_eq!(EN_US.month_from_name("May"), Some(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn space_like_and_apostrophe_groups_are_interchangeable() {
|
||||
let fr = find("fr-FR").unwrap();
|
||||
assert!(fr.is_group('\u{a0}') && fr.is_group(' ') && fr.is_group('\u{202f}'));
|
||||
assert!(!find("de-DE").unwrap().is_group(' '));
|
||||
let ch = find("de-CH").unwrap();
|
||||
assert!(ch.is_group('\'') && ch.is_group('\u{2019}'));
|
||||
assert!(!ch.is_group(' ') && !find("de-DE").unwrap().is_group('\''));
|
||||
}
|
||||
}
|
||||
+258
-10
@@ -346,19 +346,91 @@ pub fn compare_numbers(a: f64, b: f64) -> Ordering {
|
||||
round15(a).partial_cmp(&round15(b)).unwrap_or(Ordering::Equal)
|
||||
}
|
||||
|
||||
/// Case-insensitive text order (Unicode lowercase).
|
||||
/// Case-insensitive text order in three levels, as Excel's collation sorts: first by letters and
|
||||
/// digits alone (Arabic marks, tatweel and invisible format characters skipped, Arabic-Indic digits
|
||||
/// read as digits); then, letter by letter, by the marks on each letter (none first, then in
|
||||
/// Excel's mark order) and the digit script (ASCII, Arabic-Indic, Extended Arabic-Indic); then, to
|
||||
/// break a remaining tie, by every character (Unicode lowercase). Only text that is equal at the
|
||||
/// last level compares equal.
|
||||
pub fn compare_text(a: &str, b: &str) -> Ordering {
|
||||
let mut ai = a.chars().flat_map(char::to_lowercase);
|
||||
let mut bi = b.chars().flat_map(char::to_lowercase);
|
||||
loop {
|
||||
match (ai.next(), bi.next()) {
|
||||
(None, None) => return Ordering::Equal,
|
||||
(None, Some(_)) => return Ordering::Less,
|
||||
(Some(_), None) => return Ordering::Greater,
|
||||
(Some(x), Some(y)) if x != y => return x.cmp(&y),
|
||||
_ => {}
|
||||
if !(a.is_ascii() && b.is_ascii()) {
|
||||
let primary = primary_chars(a).cmp(primary_chars(b));
|
||||
if primary != Ordering::Equal {
|
||||
return primary;
|
||||
}
|
||||
let secondary = secondary_weights(a).cmp(secondary_weights(b));
|
||||
if secondary != Ordering::Equal {
|
||||
return secondary;
|
||||
}
|
||||
}
|
||||
lowercase_chars(a).cmp(lowercase_chars(b))
|
||||
}
|
||||
|
||||
fn lowercase_chars(s: &str) -> impl Iterator<Item = char> + '_ {
|
||||
s.chars().flat_map(char::to_lowercase)
|
||||
}
|
||||
|
||||
/// Characters Excel's text order skips entirely: tatweel and invisible format characters (soft
|
||||
/// hyphen, combining grapheme joiner, zero-width (non-)joiner, bidi marks, embeddings, overrides
|
||||
/// and isolates, word joiner, byte order mark). The zero-width space is not one.
|
||||
fn ignorable(c: char) -> bool {
|
||||
matches!(
|
||||
c,
|
||||
'\u{00AD}'
|
||||
| '\u{034F}'
|
||||
| '\u{061C}'
|
||||
| '\u{0640}'
|
||||
| '\u{200C}'..='\u{200F}'
|
||||
| '\u{202A}'..='\u{202E}'
|
||||
| '\u{2060}'
|
||||
| '\u{2066}'..='\u{2069}'
|
||||
| '\u{FEFF}'
|
||||
)
|
||||
}
|
||||
|
||||
/// Arabic combining marks in the order Excel sorts a letter carrying them; an unmarked letter
|
||||
/// sorts before all of them. Measured in Excel 16.0 (build 20527, en-US) with one mark on a letter.
|
||||
/// U+06D6 and U+06D7 sort as letters in Excel, so they are not listed.
|
||||
const ARABIC_MARK_ORDER: [char; 48] = [
|
||||
'\u{06EC}', '\u{0611}', '\u{0612}', '\u{0613}', '\u{0614}', '\u{06DB}', '\u{0656}', '\u{0657}', '\u{0610}', '\u{06EB}', '\u{0615}', '\u{06D9}',
|
||||
'\u{06E4}', '\u{06E1}', '\u{06DA}', '\u{06DC}', '\u{06EA}', '\u{06E3}', '\u{06D8}', '\u{0655}', '\u{065F}', '\u{0654}', '\u{06E7}', '\u{0653}',
|
||||
'\u{06E8}', '\u{06ED}', '\u{06E2}', '\u{0658}', '\u{064B}', '\u{064C}', '\u{064D}', '\u{064E}', '\u{064F}', '\u{0650}', '\u{0652}', '\u{0651}',
|
||||
'\u{0670}', '\u{0659}', '\u{065A}', '\u{065B}', '\u{065C}', '\u{065D}', '\u{065E}', '\u{0616}', '\u{0617}', '\u{0618}', '\u{0619}', '\u{061A}',
|
||||
];
|
||||
|
||||
/// Rank of an Arabic mark that letter order skips (its index in `ARABIC_MARK_ORDER`).
|
||||
fn arabic_mark_rank(c: char) -> Option<u16> {
|
||||
ARABIC_MARK_ORDER.iter().position(|&m| m == c).and_then(|i| u16::try_from(i).ok())
|
||||
}
|
||||
|
||||
/// Digit script of `c` (0 ASCII, 1 Arabic-Indic, 2 Extended Arabic-Indic) and its ASCII digit.
|
||||
fn digit(c: char) -> Option<(u16, char)> {
|
||||
let (script, zero) = match c {
|
||||
'0'..='9' => return Some((0, c)),
|
||||
'\u{0660}'..='\u{0669}' => (1, 0x0660),
|
||||
'\u{06F0}'..='\u{06F9}' => (2, 0x06F0),
|
||||
_ => return None,
|
||||
};
|
||||
char::from_u32(u32::from(c) - zero + u32::from('0')).map(|d| (script, d))
|
||||
}
|
||||
|
||||
/// The characters that decide text order before any tie-break.
|
||||
fn primary_chars(s: &str) -> impl Iterator<Item = char> + '_ {
|
||||
lowercase_chars(s).filter_map(|c| if ignorable(c) || arabic_mark_rank(c).is_some() { None } else { Some(digit(c).map_or(c, |(_, d)| d)) })
|
||||
}
|
||||
|
||||
/// The second level, for text whose primary characters are equal: per letter, its digit script and
|
||||
/// then its marks, the letters' lists closed by 0 so that an unmarked letter sorts first. Marks
|
||||
/// before the first letter form a list of their own.
|
||||
fn secondary_weights(s: &str) -> impl Iterator<Item = u16> + '_ {
|
||||
lowercase_chars(s)
|
||||
.filter(|&c| !ignorable(c))
|
||||
.flat_map(|c| match arabic_mark_rank(c) {
|
||||
Some(rank) => [Some(rank.saturating_add(1)), None],
|
||||
None => [Some(0), Some(digit(c).map_or(0, |(script, _)| script))],
|
||||
})
|
||||
.flatten()
|
||||
.chain(std::iter::once(0))
|
||||
}
|
||||
|
||||
/// Sorting order used by Sort (ascending): numbers, text, booleans, errors, then blanks last.
|
||||
@@ -457,6 +529,182 @@ mod tests {
|
||||
assert_eq!(sort_compare(&Value::Empty, &Value::Number(1.0)), Ordering::Greater);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_diacritics_sort_with_their_base_letter() {
|
||||
assert_eq!(compare_text("بَاب", "بيت"), Ordering::Less);
|
||||
assert_eq!(compare_text("بيت", "بَاب"), Ordering::Greater);
|
||||
assert_eq!(compare_text("كِتاب", "كتب"), Ordering::Less);
|
||||
assert_eq!(compare_text("رحمٰن", "رحمي"), Ordering::Less);
|
||||
assert_eq!(compare(&Value::from("بَاب"), &Value::from("بيت")), Ordering::Less);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_indic_digits_sort_with_digits() {
|
||||
assert_eq!(compare_text("١٢", "ب"), Ordering::Less);
|
||||
assert_eq!(compare_text("۱۲", "ب"), Ordering::Less);
|
||||
assert_eq!(compare_text("١", "a"), Ordering::Less);
|
||||
assert_eq!(compare_text("١٢", "13"), Ordering::Less);
|
||||
assert_eq!(compare_text("۱۴", "13"), Ordering::Greater);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tatweel_and_bidi_marks_do_not_move_sort_order() {
|
||||
assert_eq!(compare_text("بـاب", "بت"), Ordering::Less);
|
||||
assert_eq!(compare_text("\u{200F}apple", "banana"), Ordering::Less);
|
||||
assert_eq!(compare_text("\u{200E}zebra", "apple"), Ordering::Greater);
|
||||
assert_eq!(compare_text("\u{061C}a", "b"), Ordering::Less);
|
||||
assert_eq!(compare_text("a\u{200C}b", "ac"), Ordering::Less);
|
||||
assert_eq!(compare_text("a\u{200D}b", "ac"), Ordering::Less);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primary_ties_keep_equality_unchanged() {
|
||||
assert_eq!(compare_text("ABC", "abc"), Ordering::Equal);
|
||||
assert_eq!(compare_text("باب", "بَاب"), Ordering::Less);
|
||||
assert_eq!(compare_text("12", "١٢"), Ordering::Less);
|
||||
assert_eq!(compare_text("a", "\u{200F}a"), Ordering::Less);
|
||||
let samples = [
|
||||
"",
|
||||
"abc",
|
||||
"ABC",
|
||||
"abd",
|
||||
"ab",
|
||||
"باب",
|
||||
"بَاب",
|
||||
"بِاب",
|
||||
"بـاب",
|
||||
"بيت",
|
||||
"١٢",
|
||||
"۱۲",
|
||||
"12",
|
||||
"\u{200F}12",
|
||||
"\u{200F}a",
|
||||
"a",
|
||||
"A\u{200C}",
|
||||
"a\u{200F}",
|
||||
"b",
|
||||
"Straße",
|
||||
"STRASSE",
|
||||
"ΣΑΣ",
|
||||
"σας",
|
||||
"İ",
|
||||
"i̇",
|
||||
];
|
||||
let lowered = |s: &str| s.chars().flat_map(char::to_lowercase).collect::<String>();
|
||||
for a in samples {
|
||||
for b in samples {
|
||||
let order = compare_text(a, b);
|
||||
assert_eq!(order.is_eq(), lowered(a) == lowered(b), "{a:?} vs {b:?}");
|
||||
assert_eq!(order, compare_text(b, a).reverse(), "{a:?} vs {b:?}");
|
||||
for c in samples {
|
||||
if order.is_le() && compare_text(b, c).is_le() {
|
||||
assert!(compare_text(a, c).is_le(), "{a:?} <= {b:?} <= {c:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sorts `expected` from reversed and rotated starts and checks each comes back as `expected`.
|
||||
#[track_caller]
|
||||
fn assert_sorts_to(expected: &[&str]) {
|
||||
let mut reversed: Vec<&str> = expected.iter().rev().copied().collect();
|
||||
reversed.sort_by(|a, b| compare_text(a, b));
|
||||
assert_eq!(reversed, expected);
|
||||
let mut rotated: Vec<&str> = expected.to_vec();
|
||||
rotated.rotate_left(expected.len() / 2);
|
||||
rotated.sort_by(|a, b| compare_text(a, b));
|
||||
assert_eq!(rotated, expected);
|
||||
}
|
||||
|
||||
// The expected orders below are Excel's, observed with Range.Sort, =SORT() and the `<`
|
||||
// operator in Excel 16.0 (build 20527, en-US). Strings Excel ranks equal are left out.
|
||||
|
||||
#[test]
|
||||
fn excel_orders_a_letter_by_its_mark() {
|
||||
assert_sorts_to(&[
|
||||
"باب",
|
||||
"ب\u{0611}اب",
|
||||
"ب\u{0610}اب",
|
||||
"ب\u{0654}اب",
|
||||
"ب\u{064B}اب",
|
||||
"ب\u{064C}اب",
|
||||
"ب\u{064D}اب",
|
||||
"ب\u{064E}اب",
|
||||
"ب\u{064F}اب",
|
||||
"ب\u{0650}اب",
|
||||
"ب\u{0652}اب",
|
||||
"ب\u{0651}اب",
|
||||
"ب\u{0670}اب",
|
||||
"ب\u{065E}اب",
|
||||
"ب\u{061A}اب",
|
||||
]);
|
||||
// Quranic marks are skipped by letter order like harakat (hamza sorts before alef).
|
||||
assert_eq!(compare_text("ب\u{0610}ء", "باب"), Ordering::Less);
|
||||
assert_eq!(compare_text("ب\u{06E1}ء", "باب"), Ordering::Less);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excel_compares_marks_letter_by_letter() {
|
||||
assert_sorts_to(&[
|
||||
"بت",
|
||||
"بت\u{064E}",
|
||||
"بت\u{064F}",
|
||||
"بت\u{0650}",
|
||||
"ب\u{064E}ت",
|
||||
"ب\u{064E}ت\u{064E}",
|
||||
"ب\u{064E}ت\u{064F}",
|
||||
"ب\u{064F}ت",
|
||||
"ب\u{064F}ت\u{064E}",
|
||||
"ب\u{0650}ت\u{0650}",
|
||||
]);
|
||||
assert_sorts_to(&["مـحـمـد", "محم\u{0651}د", "م\u{064E}حمد", "م\u{064F}ح\u{064E}م\u{064E}\u{0651}د", "محمدي"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excel_weighs_marks_and_digit_scripts_by_position() {
|
||||
// A mark on an earlier letter outranks the digit script or letter after it, whatever the
|
||||
// code point of that next character.
|
||||
assert_sorts_to(&[
|
||||
"ب",
|
||||
"ب\u{064E}",
|
||||
"ب1",
|
||||
"ب١",
|
||||
"ب۱",
|
||||
"ب\u{064E}1",
|
||||
"ب\u{064E}١",
|
||||
"ب\u{064E}۱",
|
||||
"بa",
|
||||
"ب\u{064E}a",
|
||||
"با",
|
||||
"ب\u{064E}ا",
|
||||
"ب\u{067E}",
|
||||
"ب\u{064E}\u{067E}",
|
||||
"ب\u{06CC}",
|
||||
"ب\u{064E}\u{06CC}",
|
||||
]);
|
||||
// And an earlier digit script outranks a later mark.
|
||||
assert_sorts_to(&["12", "1٢", "١2", "١٢", "1ب", "1ب\u{064E}", "1ب\u{0650}", "١ب", "١ب\u{064E}", "١ب\u{0650}", "۱ب", "۱ب\u{064E}"]);
|
||||
assert_sorts_to(&["ب1ب", "ب1ب\u{064E}", "ب١ب", "ب\u{064E}ب", "بب1", "بب١", "ب\u{064E}ب1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excel_skips_invisible_format_characters() {
|
||||
for c in ['\u{00AD}', '\u{034F}', '\u{061C}', '\u{200C}', '\u{200D}', '\u{200E}', '\u{200F}', '\u{202A}', '\u{202B}']
|
||||
.into_iter()
|
||||
.chain(['\u{202C}', '\u{202D}', '\u{202E}', '\u{2060}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}', '\u{FEFF}'])
|
||||
{
|
||||
let s = format!("{c}b");
|
||||
assert_eq!(compare_text(&s, "a"), Ordering::Greater, "{s:?}");
|
||||
assert_eq!(compare_text(&s, "ba"), Ordering::Less, "{s:?}");
|
||||
assert_eq!(compare_text(&format!("ب{c}\u{064E}اب"), "باب\u{064B}"), Ordering::Greater, "{c:?}");
|
||||
}
|
||||
// Not even as a tie-break: a leading mark doesn't push text past marked or other-script forms.
|
||||
assert_sorts_to(&["\u{200F}باب", "ب\u{064B}اب", "ب\u{064E}اب", "ب\u{0670}اب"]);
|
||||
assert_sorts_to(&["\u{200F}12", "١٢", "۱۲"]);
|
||||
assert_sorts_to(&["ب\u{200F}ب\u{064E}", "\u{200F}ب\u{064E}ب"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors() {
|
||||
for e in CellError::ALL {
|
||||
|
||||
@@ -21,6 +21,7 @@ web-time = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
gridcraft-xlsx = { workspace = true }
|
||||
craft-csv = { workspace = true }
|
||||
gridcraft-chart = { workspace = true }
|
||||
gridcraft-pdf = { workspace = true }
|
||||
gridcraft-math = { workspace = true }
|
||||
|
||||
@@ -118,7 +118,7 @@ pub const CATALOG: &[(&str, &str, &str, &str)] = &[
|
||||
("insert.picture", "Insert", "Illustrations", "Pictures"),
|
||||
("insert.shape", "Insert", "Illustrations", "Shapes"),
|
||||
("insert.icons", "Insert", "Illustrations", "Icons"),
|
||||
("insert.smartArt", "Insert", "Illustrations", "SmartArt"),
|
||||
("insert.smartArt", "Insert", "Illustrations", "Diagram"),
|
||||
("insert.screenshot", "Insert", "Illustrations", "Screenshot"),
|
||||
("insert.recommendedCharts", "Insert", "Charts", "Recommended Charts"),
|
||||
("insert.chart", "Insert", "Charts", "Charts"),
|
||||
@@ -131,7 +131,7 @@ pub const CATALOG: &[(&str, &str, &str, &str)] = &[
|
||||
("review.newComment", "Insert", "Comments", "Comment"),
|
||||
("insert.textBox", "Insert", "Text", "Text Box"),
|
||||
("pageLayout.headerFooter", "Insert", "Text", "Header & Footer"),
|
||||
("insert.wordArt", "Insert", "Text", "WordArt"),
|
||||
("insert.wordArt", "Insert", "Text", "Decorative Text"),
|
||||
("insert.signatureLine", "Insert", "Text", "Signature Line"),
|
||||
("insert.object", "Insert", "Text", "Object"),
|
||||
("insert.equation", "Insert", "Symbols", "Equation"),
|
||||
@@ -340,3 +340,15 @@ pub fn parity() -> (usize, usize) {
|
||||
let done = ids.iter().filter(|id| crate::find_command(id).is_some()).count();
|
||||
(done, ids.len())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod feature_name_tests {
|
||||
#[test]
|
||||
fn own_feature_labels_keep_command_ids() {
|
||||
for (id, expected) in [("insert.smartArt", "Diagram"), ("insert.wordArt", "Decorative Text")] {
|
||||
let entries: Vec<_> = super::CATALOG.iter().filter(|entry| entry.0 == id).collect();
|
||||
assert_eq!(entries.len(), 1, "{id} appears once");
|
||||
assert_eq!(entries[0].3, expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+158
-4
@@ -1,7 +1,7 @@
|
||||
//! Conditional formatting evaluation: what extra style, data bar, colour scale or icon a cell
|
||||
//! gets.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef, Value, compare};
|
||||
use gridcraft_model::{CfOperator, CfRule, CfValueKind, Color, CondFormat, PatternType, Sheet, Style, Underline, Workbook, default_icon_thresholds};
|
||||
@@ -47,6 +47,8 @@ enum StatsNeed {
|
||||
fn stats_need(rule: &CfRule) -> Option<StatsNeed> {
|
||||
match rule {
|
||||
CfRule::Duplicate { .. } | CfRule::Unique { .. } => Some(StatsNeed::Counts),
|
||||
// only two- and three-colour scales paint: no statistics for the others
|
||||
CfRule::ColorScale { stops } if !matches!(stops.len(), 2 | 3) => None,
|
||||
CfRule::Top10 { .. } | CfRule::AboveAverage { .. } | CfRule::ColorScale { .. } | CfRule::DataBar { .. } | CfRule::IconSet { .. } => {
|
||||
Some(StatsNeed::Numbers)
|
||||
}
|
||||
@@ -70,15 +72,58 @@ thread_local! {
|
||||
static SCANNED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Most cells one rule's statistics visit, across all of its ranges together: a rule repeating
|
||||
/// the same range many times must not multiply the scan.
|
||||
const STATS_SCAN_LIMIT: usize = 2_000_000;
|
||||
|
||||
/// Steps through the spilled cells inside `r`, row-major: `Some(cell)` for each, and `None` for a
|
||||
/// jump past the spills left or right of the columns on a row (one step per row, however many
|
||||
/// there are), so unrelated spills between a narrow range's corners cost little.
|
||||
fn spill_steps(sh: &Sheet, r: RangeRef) -> impl Iterator<Item = Option<CellRef>> + '_ {
|
||||
let mut cur = Some(r.start);
|
||||
std::iter::from_fn(move || {
|
||||
let at = cur.filter(|at| *at <= r.end)?;
|
||||
let (&c, _) = sh.spill.range(at..=r.end).next()?;
|
||||
if c.col < r.start.col {
|
||||
cur = Some(CellRef::new(c.row, r.start.col));
|
||||
Some(None)
|
||||
} else if c.col > r.end.col {
|
||||
cur = c.row.checked_add(1).map(|row| CellRef::new(row, r.start.col));
|
||||
Some(None)
|
||||
} else {
|
||||
cur = c.col.checked_add(1).map(|col| CellRef::new(c.row, col));
|
||||
Some(Some(c))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn numbers_in(sh: &Sheet, ranges: &[RangeRef], need: StatsNeed) -> (Vec<f64>, HashMap<String, usize>) {
|
||||
let mut nums = Vec::new();
|
||||
let mut counts = HashMap::new();
|
||||
let mut budget = STATS_SCAN_LIMIT;
|
||||
let mut seen: HashSet<RangeRef> = HashSet::new();
|
||||
let used = sh.used_range();
|
||||
for r in ranges {
|
||||
let r = match sh.used_range().and_then(|u| r.intersection(&u)) {
|
||||
if budget == 0 {
|
||||
break;
|
||||
}
|
||||
let r = match used.and_then(|u| r.intersection(&u)) {
|
||||
Some(x) => x,
|
||||
None => continue,
|
||||
};
|
||||
for c in r.iter().take(2_000_000) {
|
||||
// The same range listed twice counts its cells once.
|
||||
if !seen.insert(r) {
|
||||
continue;
|
||||
}
|
||||
// Only stored and spilled cells hold values: blank cells of a sparse range cost nothing,
|
||||
// so a value far down a huge range is still counted. Every cell looked at is charged.
|
||||
let stored = sh.cells.iter_range(r).map(|(c, _)| (Some(c), true));
|
||||
let spilled = spill_steps(sh, r).map(|c| (c, false));
|
||||
for (c, is_stored) in stored.chain(spilled).take(budget) {
|
||||
budget -= 1;
|
||||
// a skip over spills outside the columns (charged, not read), or a spilled value
|
||||
// under a stored cell, which was read with it
|
||||
let Some(c) = c.filter(|c| is_stored || sh.cells.get(*c).is_none()) else { continue };
|
||||
#[cfg(test)]
|
||||
SCANNED.with(|n| n.set(n.get() + 1));
|
||||
let v = sh.value(c);
|
||||
@@ -278,7 +323,8 @@ impl CfCache {
|
||||
_ => None,
|
||||
},
|
||||
CfRule::ColorScale { stops } => {
|
||||
if let Some(n) = v.as_f64() {
|
||||
// Only two- and three-colour scales paint: evaluate no thresholds for others.
|
||||
if let Some(n) = v.as_f64().filter(|_| matches!(stops.len(), 2 | 3)) {
|
||||
let pts: Vec<(f64, [u8; 3])> = stops
|
||||
.iter()
|
||||
.map(|(k, col)| (threshold(wb, sheet, c, st, k), col.resolve(&wb.theme).unwrap_or([255, 255, 255])))
|
||||
@@ -368,6 +414,9 @@ impl CfCache {
|
||||
/// Adds the parts `lower` sets that `style` leaves unset (differential styles: default parts
|
||||
/// are unset).
|
||||
fn layer_under(style: &mut Style, lower: &Style) {
|
||||
if style.align.reading_order.is_none() {
|
||||
style.align.reading_order = lower.align.reading_order;
|
||||
}
|
||||
if style.fill.pattern == PatternType::None {
|
||||
style.fill = lower.fill;
|
||||
}
|
||||
@@ -417,6 +466,28 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reading_order_respects_conditional_rule_priority_and_explicit_context() {
|
||||
for (higher, expected) in [
|
||||
(json!({"font": {"bold": true}}), "RightToLeft"),
|
||||
(json!({"align": {"reading_order": "Context"}}), "Context"),
|
||||
(json!({"align": {"reading_order": "LeftToRight"}}), "LeftToRight"),
|
||||
] {
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "مرحبا 123 ABC"})).unwrap();
|
||||
for style in [json!({"align": {"reading_order": "RightToLeft"}}), higher] {
|
||||
s.execute("home.conditionalFormat", json!({"range": "A1", "rule": {"type": "noBlanks", "style": style}})).unwrap();
|
||||
}
|
||||
let wb = &s.doc().unwrap().wb;
|
||||
let (reopened, _) = gridcraft_xlsx::read_xlsx(&gridcraft_xlsx::write_xlsx(wb).unwrap()).unwrap();
|
||||
for wb in [wb, &reopened] {
|
||||
let style = CfCache::new().look(wb, 0, CellRef::new(0, 0)).unwrap().style.unwrap();
|
||||
assert_eq!(serde_json::to_value(style.align).unwrap()["reading_order"], expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_matching_rule_applies() {
|
||||
let mut s = crate::Session::new();
|
||||
@@ -560,6 +631,89 @@ mod tests {
|
||||
assert_eq!(SCANNED.with(|n| n.get()), 10_000);
|
||||
}
|
||||
|
||||
/// A rule listing the same range many times scans it once, and never more than the shared
|
||||
/// limit however many ranges it holds.
|
||||
#[test]
|
||||
fn repeated_ranges_share_one_scan_budget() {
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2], [3]]})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "XFD1048576", "values": [[4]]})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.sheet(0).unwrap();
|
||||
let dense = RangeRef::parse("A1:XFD1048576").unwrap();
|
||||
let small = RangeRef::parse("A1:A3").unwrap();
|
||||
SCANNED.with(|n| n.set(0));
|
||||
let st = RuleStats::build(sh, &vec![small; 1000], StatsNeed::Numbers);
|
||||
assert_eq!(st.sorted, vec![1.0, 2.0, 3.0]);
|
||||
assert_eq!(SCANNED.with(|n| n.get()), 3);
|
||||
let shifted: Vec<RangeRef> = (0..50).map(|i| RangeRef::new(CellRef::new(i, 0), dense.end)).collect();
|
||||
SCANNED.with(|n| n.set(0));
|
||||
RuleStats::build(sh, &shifted, StatsNeed::Numbers);
|
||||
assert!(SCANNED.with(|n| n.get()) <= STATS_SCAN_LIMIT);
|
||||
}
|
||||
|
||||
/// Blank cells of a sparse range are not charged to the scan budget: a value at the end of two
|
||||
/// million-row columns still counts, so duplicates are found.
|
||||
#[test]
|
||||
fn sparse_ranges_keep_their_values_under_the_scan_budget() {
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[7]]})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "B1048576", "values": [[7]]})).unwrap();
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.sheet(0).unwrap();
|
||||
let ranges = [RangeRef::parse("A1:A1048576").unwrap(), RangeRef::parse("B1:B1048576").unwrap()];
|
||||
SCANNED.with(|n| n.set(0));
|
||||
let st = RuleStats::build(sh, &ranges, StatsNeed::Counts);
|
||||
assert_eq!(st.counts.get("7"), Some(&2));
|
||||
assert_eq!(SCANNED.with(|n| n.get()), 2);
|
||||
}
|
||||
|
||||
/// Spills left and right of a narrow range, between its corners, are skipped a row at a time:
|
||||
/// they do not use the budget that the in-range spills need.
|
||||
#[test]
|
||||
fn spills_outside_a_narrow_range_do_not_use_its_scan_budget() {
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
let d = s.doc().unwrap();
|
||||
let mut sh = d.wb.sheet(0).unwrap().clone();
|
||||
for row in 0..1000 {
|
||||
for col in [0u32, 5, 6, 7] {
|
||||
sh.spill.insert(CellRef::new(row, col), Value::Number(1.0));
|
||||
}
|
||||
}
|
||||
let r = RangeRef::parse("F1:G1000").unwrap();
|
||||
let steps: Vec<_> = spill_steps(&sh, r).collect();
|
||||
assert_eq!(steps.iter().flatten().count(), 2000);
|
||||
// column A is skipped one step a row, never one per cell
|
||||
assert!(steps.len() <= 2000 + 1000, "{}", steps.len());
|
||||
let narrow = RangeRef::parse("C1:C1000").unwrap();
|
||||
assert_eq!(spill_steps(&sh, narrow).flatten().count(), 0);
|
||||
}
|
||||
|
||||
/// A colour scale with an unsupported number of stops paints nothing and evaluates none of them.
|
||||
#[test]
|
||||
fn colour_scales_with_other_stop_counts_paint_nothing() {
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [5]]})).unwrap();
|
||||
let mut wb: Workbook = (*s.doc().unwrap().wb).clone();
|
||||
let stop = (CfValueKind::Formula("=1".into()), Color::default());
|
||||
let rule = |n: usize| CondFormat {
|
||||
ranges: vec![RangeRef::parse("A1:A2").unwrap()],
|
||||
rule: CfRule::ColorScale { stops: vec![stop.clone(); n] },
|
||||
priority: 1,
|
||||
stop_if_true: false,
|
||||
};
|
||||
wb.sheet_mut(0).unwrap().cond_formats.push(rule(4));
|
||||
let mut cache = CfCache::new();
|
||||
assert_eq!(cache.look(&wb, 0, CellRef::parse("A1").unwrap()).and_then(|l| l.scale_fill), None);
|
||||
wb.sheet_mut(0).unwrap().cond_formats = vec![rule(2)];
|
||||
let mut cache = CfCache::new();
|
||||
assert!(cache.look(&wb, 0, CellRef::parse("A1").unwrap()).and_then(|l| l.scale_fill).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_value_operator_names() {
|
||||
let mut s = crate::Session::new();
|
||||
|
||||
@@ -10,6 +10,10 @@ use crate::io::esc;
|
||||
/// Widest or tallest chart written; larger anchors are clamped.
|
||||
const MAX_SIDE: f32 = 16_384.0;
|
||||
|
||||
/// Most bytes of SVG written for the charts of one web page together. A chart past what is
|
||||
/// left makes the export fail rather than grow without bound or be dropped without a word.
|
||||
pub(crate) const MAX_PAGE_SVG_BYTES: usize = 64 * 1024 * 1024;
|
||||
|
||||
struct HelveticaMeasure;
|
||||
|
||||
impl gridcraft_chart::Measure for HelveticaMeasure {
|
||||
@@ -18,24 +22,40 @@ impl gridcraft_chart::Measure for HelveticaMeasure {
|
||||
}
|
||||
}
|
||||
|
||||
/// `chart` of sheet `sheet` as an `<svg>` element placed by `style`, or `None` for a degenerate size.
|
||||
pub(crate) fn chart_svg(wb: &Workbook, sheet: usize, chart: &Chart, style: &str) -> Option<String> {
|
||||
/// `chart` of sheet `sheet` as an `<svg>` element placed by `style`, `None` for a degenerate size,
|
||||
/// or an error for an element longer than `budget` bytes.
|
||||
pub(crate) fn chart_svg(wb: &Workbook, sheet: usize, chart: &Chart, style: &str, budget: usize) -> crate::Result<Option<String>> {
|
||||
let (w, h) = (chart.anchor.width.min(MAX_SIDE), chart.anchor.height.min(MAX_SIDE));
|
||||
if !(w >= 1.0 && h >= 1.0) {
|
||||
return None;
|
||||
return Ok(None);
|
||||
}
|
||||
let data = gridcraft_chart::resolve(wb, sheet, chart);
|
||||
let prims = gridcraft_chart::render(chart, &data, w, h, &HelveticaMeasure);
|
||||
Some(prims_svg(&prims, w, h, style))
|
||||
match prims_svg_within(&prims, w, h, style, budget) {
|
||||
Some(svg) => Ok(Some(svg)),
|
||||
None => Err(crate::EngineError::Other(format!(
|
||||
"the charts would make the web page over {} MiB. Save as .xlsx or PDF instead.",
|
||||
MAX_PAGE_SVG_BYTES / (1024 * 1024)
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn prims_svg(prims: &[Prim], w: f32, h: f32, style: &str) -> String {
|
||||
prims_svg_within(prims, w, h, style, usize::MAX).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The `<svg>` element, or `None` as soon as it grows past `budget` bytes.
|
||||
fn prims_svg_within(prims: &[Prim], w: f32, h: f32, style: &str, budget: usize) -> Option<String> {
|
||||
let mut out = String::new();
|
||||
let _ = write!(
|
||||
out,
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w}\" height=\"{h}\" viewBox=\"0 0 {w} {h}\" font-family=\"Helvetica,Arial,sans-serif\" style=\"{style}\">"
|
||||
);
|
||||
for p in prims {
|
||||
if out.len() > budget {
|
||||
return None;
|
||||
}
|
||||
match p {
|
||||
Prim::Rect { x, y, w, h, fill, stroke, radius } => {
|
||||
let _ = write!(out, "<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\"", num(*x), num(*y), num(*w), num(*h));
|
||||
@@ -92,7 +112,7 @@ fn prims_svg(prims: &[Prim], w: f32, h: f32, style: &str) -> String {
|
||||
}
|
||||
}
|
||||
out.push_str("</svg>");
|
||||
out
|
||||
(out.len() <= budget).then_some(out)
|
||||
}
|
||||
|
||||
/// Fill and stroke attributes of an open shape element; the caller closes it.
|
||||
@@ -149,6 +169,17 @@ fn num(v: f32) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_svg_past_its_byte_budget_is_left_out() {
|
||||
let rect = Prim::Rect { x: 1.0, y: 2.0, w: 3.0, h: 4.0, fill: Some([255, 0, 0, 255]), stroke: None, radius: 0.0 };
|
||||
let prims = vec![rect; 10_000];
|
||||
let full = prims_svg(&prims, 100.0, 50.0, "");
|
||||
assert!(full.len() > 100_000);
|
||||
assert_eq!(prims_svg_within(&prims, 100.0, 50.0, "", full.len()), Some(full.clone()));
|
||||
assert_eq!(prims_svg_within(&prims, 100.0, 50.0, "", full.len() - 1), None);
|
||||
assert_eq!(prims_svg_within(&prims, 100.0, 50.0, "", 1_000), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_primitive_becomes_an_svg_element() {
|
||||
let red = [255, 0, 0, 255];
|
||||
|
||||
+150
-5
@@ -366,6 +366,10 @@ pub struct Ctx<'a> {
|
||||
pub wb: Workbook,
|
||||
pub changed: Vec<Key>,
|
||||
pub structural: bool,
|
||||
/// Recalculate every formula without the other effects of `structural` (table headers are not
|
||||
/// resynchronized, array formulas are still checked): for edits that change only what a
|
||||
/// function reads, like sheet protection.
|
||||
pub recalc_all: bool,
|
||||
pub sel: &'a mut Selection,
|
||||
/// Rows whose automatic height should be recomputed (sheet, row).
|
||||
pub fit_rows: Vec<(usize, u32)>,
|
||||
@@ -400,13 +404,14 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
|
||||
wb: (*d.wb).clone(),
|
||||
changed: Vec::new(),
|
||||
structural: false,
|
||||
recalc_all: false,
|
||||
sel: &mut sel,
|
||||
fit_rows: Vec::new(),
|
||||
protection_checked: false,
|
||||
edits: Vec::new(),
|
||||
};
|
||||
let r = f(&mut ctx)?;
|
||||
let Ctx { mut wb, mut changed, structural, mut fit_rows, protection_checked, edits, .. } = ctx;
|
||||
let Ctx { mut wb, mut changed, structural, recalc_all, mut fit_rows, protection_checked, edits, .. } = ctx;
|
||||
// Equations whose source changed (or that arrived without a layout, as from a paste) are
|
||||
// laid out here, so painting only ever reads the cache.
|
||||
wb.refresh_equations();
|
||||
@@ -435,7 +440,7 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
|
||||
if let Some(t0) = t0 {
|
||||
eprintln!("commit: edit {:?}, {} changed", since(t0), changed.len());
|
||||
}
|
||||
if structural {
|
||||
if structural || recalc_all {
|
||||
d.calc.recalc_all(&mut wb);
|
||||
} else if !changed.is_empty() {
|
||||
d.calc.cells_changed(&mut wb, &changed);
|
||||
@@ -446,8 +451,18 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
|
||||
}
|
||||
fit_rows.sort_unstable();
|
||||
fit_rows.dedup();
|
||||
let mut sweep: Option<(usize, MergeSweep)> = None;
|
||||
let mut left = MERGE_FIT_WORK;
|
||||
for (si, row) in fit_rows {
|
||||
auto_row_height(&mut wb, si, row);
|
||||
if sweep.as_ref().is_none_or(|(at, _)| *at != si) {
|
||||
if let Some((_, old)) = &sweep {
|
||||
left = old.left();
|
||||
}
|
||||
sweep = Some((si, MergeSweep::new(left)));
|
||||
}
|
||||
if let Some((_, sw)) = sweep.as_mut() {
|
||||
fit_row_height(&mut wb, si, row, sw);
|
||||
}
|
||||
}
|
||||
if let Some(t0) = t0 {
|
||||
eprintln!("commit: + rows {:?}", since(t0));
|
||||
@@ -721,6 +736,94 @@ fn wrapped_line_count(line: &str, width: f32, size: f32, bold: bool) -> f32 {
|
||||
/// Line breaks in a cell's text count only when the cell wraps, as in Excel: with Wrap Text off
|
||||
/// a multi-line value shows on one line and AutoFit gives a one-line row.
|
||||
pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) {
|
||||
let mut sweep = MergeSweep::new(MERGE_FIT_WORK);
|
||||
fit_row_height(wb, si, row, &mut sweep);
|
||||
}
|
||||
|
||||
/// Most merge comparisons one auto-fit pass (a commit, or opening a workbook) makes. Past it the
|
||||
/// remaining rows keep the heights they have rather than stall on a sheet whose merges overlap
|
||||
/// the fitted rows many times over.
|
||||
pub(crate) const MERGE_FIT_WORK: usize = 50_000_000;
|
||||
|
||||
/// The merged areas of one sheet that touch a row, for rows visited in ascending order: the
|
||||
/// merges are sorted by first row once and swept, so fitting many rows never rescans every merge
|
||||
/// per row. All comparisons count against [`MERGE_FIT_WORK`].
|
||||
pub(crate) struct MergeSweep {
|
||||
/// Built on the first row that needs merge checks, so rows that return early never pay for it.
|
||||
by_start: Option<Vec<gridcraft_core::RangeRef>>,
|
||||
next: usize,
|
||||
active: Vec<gridcraft_core::RangeRef>,
|
||||
budget: usize,
|
||||
/// The first row was answered by a plain scan: a pass that fits one row never pays for a sort.
|
||||
scanned: bool,
|
||||
}
|
||||
|
||||
impl MergeSweep {
|
||||
/// A sweep for one sheet's merges with `budget` comparisons left for the pass (carry
|
||||
/// [`MergeSweep::left`] of the previous sheet's sweep into the next). Every call to
|
||||
/// [`MergeSweep::at`] must pass that same sheet.
|
||||
pub(crate) fn new(budget: usize) -> Self {
|
||||
Self { by_start: None, next: 0, active: Vec::new(), budget, scanned: false }
|
||||
}
|
||||
|
||||
/// Comparisons left for the rest of the pass.
|
||||
pub(crate) fn left(&self) -> usize {
|
||||
self.budget
|
||||
}
|
||||
|
||||
/// Takes `n` comparisons from the budget; `false` once it is spent.
|
||||
fn charge(&mut self, n: usize) -> bool {
|
||||
match self.budget.checked_sub(n) {
|
||||
Some(rest) => {
|
||||
self.budget = rest;
|
||||
true
|
||||
}
|
||||
None => {
|
||||
self.budget = 0;
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The merges covering `row`, or `None` when the budget is spent. Rows must not decrease
|
||||
/// between calls (skipping rows is fine).
|
||||
fn at(&mut self, sh: &gridcraft_model::Sheet, row: u32) -> Option<Vec<gridcraft_core::RangeRef>> {
|
||||
if self.by_start.is_none() && !self.scanned {
|
||||
self.scanned = true;
|
||||
if !self.charge(sh.merges.len()) {
|
||||
return None;
|
||||
}
|
||||
return Some(sh.merges.iter().copied().filter(|m| !m.is_single() && m.start.row <= row && row <= m.end.row).collect());
|
||||
}
|
||||
if self.by_start.is_none() {
|
||||
// Sorting is about n log2 n comparisons, charged before it starts.
|
||||
let n = sh.merges.len();
|
||||
if !self.charge(n.saturating_mul((usize::BITS - n.leading_zeros()) as usize)) {
|
||||
return None;
|
||||
}
|
||||
let mut v: Vec<gridcraft_core::RangeRef> = sh.merges.iter().copied().filter(|m| !m.is_single()).collect();
|
||||
v.sort_unstable_by_key(|m| m.start.row);
|
||||
self.by_start = Some(v);
|
||||
}
|
||||
let by_start = self.by_start.as_ref()?;
|
||||
while let Some(m) = by_start.get(self.next).filter(|m| m.start.row <= row) {
|
||||
self.active.push(*m);
|
||||
self.next += 1;
|
||||
}
|
||||
if !self.charge(self.active.len()) {
|
||||
return None;
|
||||
}
|
||||
self.active.retain(|m| m.end.row >= row);
|
||||
Some(self.active.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// [`auto_row_height`] with the sheet's merges taken from `sweep`.
|
||||
pub(crate) fn fit_row_height(wb: &mut Workbook, si: usize, row: u32, sweep: &mut MergeSweep) {
|
||||
// Out of budget: the row keeps the height it has.
|
||||
if sweep.left() == 0 {
|
||||
return;
|
||||
}
|
||||
let Some(sh) = wb.sheet(si) else { return };
|
||||
if sh.rows.get(&row).is_some_and(|i| i.custom) {
|
||||
return;
|
||||
@@ -750,9 +853,14 @@ pub fn auto_row_height(wb: &mut Workbook, si: usize, row: u32) {
|
||||
// picked out once, and per cell the candidates are re-checked (normally none or a couple;
|
||||
// a sheet with many merges and many saved columns would otherwise scan all of them per
|
||||
// cell, rows × merges × columns).
|
||||
let row_merges: Vec<gridcraft_core::RangeRef> =
|
||||
sh.merges.iter().copied().filter(|m| !m.is_single() && m.start.row <= row && row <= m.end.row).collect();
|
||||
// Rows are fitted in ascending order, so the merges touching this one come from a sweep over
|
||||
// the sheet's merges sorted once, not from a scan of all of them. Out of budget: the row keeps
|
||||
// the height it has.
|
||||
let Some(row_merges) = sweep.at(sh, row) else { return };
|
||||
for (c, cell) in sh.cells.row(row, 0, gridcraft_core::MAX_COLS - 1) {
|
||||
if !sweep.charge(row_merges.len()) {
|
||||
return;
|
||||
}
|
||||
// Merged cells never auto-fit their rows, like Excel (column autofit skips them too).
|
||||
if row_merges.iter().any(|m| m.contains(CellRef::new(row, c))) {
|
||||
continue;
|
||||
@@ -796,3 +904,40 @@ impl Ctx<'_> {
|
||||
self.changed.push((sheet, c));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod merge_sweep_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_budget_carries_from_one_sheets_sweep_to_the_next() {
|
||||
let mut sheet = gridcraft_model::Sheet::new("S");
|
||||
for r in ["A1:B2", "C1:D2", "E1:F2"] {
|
||||
sheet.merges.push(gridcraft_core::RangeRef::parse(r).unwrap());
|
||||
}
|
||||
let mut first = MergeSweep::new(5);
|
||||
assert!(first.at(&sheet, 0).is_some(), "the first row is a plain scan: three comparisons");
|
||||
assert!(first.at(&sheet, 1).is_none(), "the second row needs the sort: six comparisons, two are left");
|
||||
assert_eq!(first.left(), 0);
|
||||
// The next sheet starts with what is left: nothing, so it fits nothing.
|
||||
let mut second = MergeSweep::new(first.left());
|
||||
assert!(second.at(&sheet, 0).is_none());
|
||||
assert!(MergeSweep::new(MERGE_FIT_WORK).at(&sheet, 0).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorting_the_merges_is_charged() {
|
||||
let mut sheet = gridcraft_model::Sheet::new("S");
|
||||
for r in ["A1:B2", "C1:D2", "E1:F2", "G1:H2"] {
|
||||
sheet.merges.push(gridcraft_core::RangeRef::parse(r).unwrap());
|
||||
}
|
||||
// The first row costs a plain scan (4); from the second on the merges are sorted, about
|
||||
// 4 * 3 comparisons: 7 left is not enough, 12 plus the row's 4 is.
|
||||
let mut sweep = MergeSweep::new(11);
|
||||
assert!(sweep.at(&sheet, 0).is_some());
|
||||
assert!(sweep.at(&sheet, 1).is_none());
|
||||
assert_eq!(sweep.left(), 0);
|
||||
let mut sweep = MergeSweep::new(20);
|
||||
assert!(sweep.at(&sheet, 0).is_some() && sweep.at(&sheet, 1).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,26 +152,32 @@ fn input_range(s: &Session, p: &Json, keys: &[&str]) -> Result<(usize, RangeRef)
|
||||
/// Splits a range into variables (columns, or rows when `by_rows`); with `labels` the first
|
||||
/// cell of each names it, else `default(i)` does.
|
||||
fn variables(s: &Session, si: usize, r: RangeRef, by_rows: bool, labels: bool, default: &dyn Fn(usize) -> String) -> Result<Vec<Var>> {
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
|
||||
let (outer, inner) = if by_rows { (r.height(), r.width()) } else { (r.width(), r.height()) };
|
||||
let outer = if by_rows { r.height() } else { r.width() };
|
||||
let mut out = Vec::with_capacity(outer as usize);
|
||||
for i in 0..outer {
|
||||
let cell = |j: u32| if by_rows { CellRef::new(r.start.row + i, r.start.col + j) } else { CellRef::new(r.start.row + j, r.start.col + i) };
|
||||
let mut vals: Vec<Value> = (0..inner).map(|j| sh.value(cell(j))).collect();
|
||||
let mut name = default(i as usize);
|
||||
if labels && !vals.is_empty() {
|
||||
let first = vals.remove(0);
|
||||
let t = first.display();
|
||||
if !t.trim().is_empty() {
|
||||
name = t;
|
||||
}
|
||||
}
|
||||
out.push(Var { name, vals });
|
||||
out.push(variable(s, si, r, by_rows, labels, default, i)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Variable `i` of [`variables`], read alone so a caller that may stop early needn't build them all.
|
||||
fn variable(s: &Session, si: usize, r: RangeRef, by_rows: bool, labels: bool, default: &dyn Fn(usize) -> String, i: u32) -> Result<Var> {
|
||||
let d = s.doc()?;
|
||||
let sh = d.wb.sheet(si).ok_or(EngineError::NoDocument)?;
|
||||
let inner = if by_rows { r.width() } else { r.height() };
|
||||
let cell = |j: u32| if by_rows { CellRef::new(r.start.row + i, r.start.col + j) } else { CellRef::new(r.start.row + j, r.start.col + i) };
|
||||
let mut vals: Vec<Value> = (0..inner).map(|j| sh.value(cell(j))).collect();
|
||||
let mut name = default(i as usize);
|
||||
if labels && !vals.is_empty() {
|
||||
let first = vals.remove(0);
|
||||
let t = first.display();
|
||||
if !t.trim().is_empty() {
|
||||
name = t;
|
||||
}
|
||||
}
|
||||
Ok(Var { name, vals })
|
||||
}
|
||||
|
||||
/// The numbers of a variable; blanks are skipped when `skip_blank`, anything else that isn't a
|
||||
/// number is an error.
|
||||
fn numbers(v: &[Value], skip_blank: bool) -> Result<Vec<f64>> {
|
||||
@@ -272,11 +278,25 @@ struct Grid {
|
||||
cells: BTreeMap<(u32, u32), Value>,
|
||||
/// Cells shown as percentages.
|
||||
pct: Vec<(u32, u32)>,
|
||||
/// Rows and columns spanned by `cells`.
|
||||
rows: u32,
|
||||
cols: u32,
|
||||
/// A cell was dropped because the report outgrew [`MAX_OUTPUT_CELLS`]: the report is refused.
|
||||
too_large: bool,
|
||||
}
|
||||
|
||||
impl Grid {
|
||||
/// Stores a value. Past [`MAX_OUTPUT_CELLS`] (the most a report writes) nothing more is kept,
|
||||
/// so a builder that would produce millions of entries stops allocating them, and
|
||||
/// [`write_report`] refuses the report.
|
||||
fn put(&mut self, r: u32, c: u32, v: Value) {
|
||||
if !v.is_empty() {
|
||||
let (rows, cols) = (self.rows.max(r.saturating_add(1)), self.cols.max(c.saturating_add(1)));
|
||||
if self.too_large || u64::from(rows) * u64::from(cols) > MAX_OUTPUT_CELLS {
|
||||
self.too_large = true;
|
||||
return;
|
||||
}
|
||||
(self.rows, self.cols) = (rows, cols);
|
||||
self.cells.insert((r, c), v);
|
||||
}
|
||||
}
|
||||
@@ -296,9 +316,7 @@ impl Grid {
|
||||
}
|
||||
/// (rows, cols) spanned.
|
||||
fn extent(&self) -> (u32, u32) {
|
||||
let rows = self.cells.keys().map(|(r, _)| r + 1).max().unwrap_or(0);
|
||||
let cols = self.cells.keys().map(|(_, c)| c + 1).max().unwrap_or(0);
|
||||
(rows, cols)
|
||||
(self.rows, self.cols)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,7 +328,7 @@ fn idx(i: usize) -> u32 {
|
||||
|
||||
fn descriptive(s: &Session, p: &Json) -> Result<Grid> {
|
||||
let (si, r) = input_range(s, p, &["range", "input"])?;
|
||||
let vars = variables(s, si, r, by_rows(p), flag(p, "labels"), &|i| format!("Column{}", i + 1))?;
|
||||
let (by_rows, labels) = (by_rows(p), flag(p, "labels"));
|
||||
let summary = p.get("summary").map(|_| flag(p, "summary")).unwrap_or(true);
|
||||
let conf = confidence(p, "confidence")?;
|
||||
let kl = num(p, "kthLargest").filter(|k| *k >= 1.0).map(|k| k as usize);
|
||||
@@ -319,7 +337,14 @@ fn descriptive(s: &Session, p: &Json) -> Result<Grid> {
|
||||
return Err(bad(CMD, "choose at least one of summary, confidence, kthLargest or kthSmallest"));
|
||||
}
|
||||
let mut g = Grid::default();
|
||||
for (i, v) in vars.iter().enumerate() {
|
||||
// Variables are read one at a time: a million-row range grouped by rows is a million variables.
|
||||
for i in 0..(if by_rows { r.height() } else { r.width() }) {
|
||||
// The report is refused once the grid is too large: the rest would only build discarded labels.
|
||||
if g.too_large {
|
||||
break;
|
||||
}
|
||||
let v = variable(s, si, r, by_rows, labels, &|i| format!("Column{}", i + 1), i)?;
|
||||
let i = i as usize;
|
||||
let x = numbers(&v.vals, true)?;
|
||||
let c = idx(i).saturating_mul(2);
|
||||
g.txt(0, c, &v.name);
|
||||
@@ -1749,7 +1774,7 @@ fn sampling(s: &Session, p: &Json, seed: Option<u64>) -> Result<Grid> {
|
||||
fn write_report(s: &mut Session, p: &Json, tool: &str, g: Grid) -> Result<Json> {
|
||||
let (rows, cols) = g.extent();
|
||||
let (rows, cols) = (rows.max(1), cols.max(1));
|
||||
if u64::from(rows) * u64::from(cols) > MAX_OUTPUT_CELLS {
|
||||
if g.too_large || u64::from(rows) * u64::from(cols) > MAX_OUTPUT_CELLS {
|
||||
return Err(bad(CMD, "the report is too large"));
|
||||
}
|
||||
let d = s.doc()?;
|
||||
@@ -1914,6 +1939,28 @@ mod tests {
|
||||
x
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_report_stops_growing_past_the_output_limit() {
|
||||
// Entries past the limit are not kept, and the report is refused.
|
||||
let mut g = super::Grid::default();
|
||||
g.num(1999, 1999, 1.0);
|
||||
assert!(!g.too_large);
|
||||
g.num(0, 2000, 1.0);
|
||||
assert!(g.too_large);
|
||||
assert_eq!(g.cells.len(), 1);
|
||||
assert_eq!(g.extent(), (2000, 2000));
|
||||
// Row-grouped statistics of a tall, almost empty column: one variable per row would make
|
||||
// millions of report entries; the report is refused instead of built.
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1]]})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "A1000000", "values": [[2]]})).unwrap();
|
||||
let err = s
|
||||
.execute("data.analysisToolPak", json!({"tool": "descriptive", "range": "A1:A1000000", "groupedBy": "rows", "output": "C1"}))
|
||||
.expect_err("report too large");
|
||||
assert!(err.to_string().contains("too large"), "{err}");
|
||||
assert_eq!(v(&s, "C1"), Value::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forecast_standard_errors_match_the_full_window_rescan() {
|
||||
// #1043: the running sum must give what re-adding every window gave, gaps included.
|
||||
|
||||
@@ -98,7 +98,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
validation
|
||||
),
|
||||
cmd!(query "data.validate", "Check Value Against Validation", [], None, "{cell?, input} → {ok, message?}", has_doc, validate_cmd),
|
||||
cmd!(noundo "data.circleInvalid", "Circle Invalid Data", ["Data", "Data Tools", "Data Validation"], None, "{} → invalid cells", has_doc, circle_invalid),
|
||||
cmd!(noundo "data.circleInvalid", "Circle Invalid Data", ["Data", "Data Tools", "Data Validation"], None, "{} → {invalid: [cell], truncated: bool (true when the 100,000-cell scan allowance ended before every range was checked)}", has_doc, circle_invalid),
|
||||
CommandSpec {
|
||||
shortcut_mac: Some("Cmd+Shift+K"),
|
||||
..cmd!(
|
||||
|
||||
@@ -184,7 +184,8 @@ pub(crate) fn grow_autofilter(sh: &mut Sheet, at: CellRef) {
|
||||
pub(crate) fn apply_filter(wb: &mut Workbook, sheet: usize) {
|
||||
let Some(sh) = wb.sheet(sheet) else { return };
|
||||
let Some(af) = sh.autofilter.clone() else { return };
|
||||
let r = af.range;
|
||||
// An imported range can name rows past the grid; none of those exist to hide.
|
||||
let r = RangeRef::new(af.range.start, CellRef::new(af.range.end.row.min(gridcraft_core::MAX_ROWS - 1), af.range.end.col));
|
||||
let mut hide = Vec::new();
|
||||
// Precompute per-criterion top10 threshold and average once, not per row.
|
||||
let mut col_stats: std::collections::HashMap<usize, (Option<f64>, f64)> = std::collections::HashMap::new();
|
||||
@@ -317,5 +318,9 @@ pub fn filter_values(wb: &Workbook, sheet: usize, col: u32) -> Vec<(String, bool
|
||||
let key = if blank { sort_key(&Value::Empty) } else { sort_key(&v) };
|
||||
seen.entry((key, t)).or_insert(on);
|
||||
}
|
||||
seen.into_iter().map(|((_, t), on)| (t, on)).collect()
|
||||
let mut values: Vec<_> = seen.into_iter().collect();
|
||||
values.sort_by(|((a, ta), _), ((b, tb), _)| {
|
||||
a.0.cmp(&b.0).then(a.1.cmp(&b.1)).then_with(|| gridcraft_core::compare_text(&a.2, &b.2)).then_with(|| ta.cmp(tb))
|
||||
});
|
||||
values.into_iter().map(|((_, t), on)| (t, on)).collect()
|
||||
}
|
||||
|
||||
@@ -53,6 +53,17 @@ fn subtotal_puts_summary_rows_below_their_detail() {
|
||||
assert_eq!(sh.value(CellRef::new(3, 0)).display(), "West");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_list_orders_arabic_like_sort() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["H"], ["بج"], ["بُت"], ["\u{200F}بب"]]})).unwrap();
|
||||
s.execute("data.filter", json!({"range": "A1:A4"})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let list: Vec<String> = filter_values(&doc.wb, 0, 0).into_iter().map(|(t, _)| t).collect();
|
||||
assert_eq!(list, ["\u{200F}بب", "بُت", "بج"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_by_huge_numeric_column_is_rejected_not_overflowed() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -5,6 +5,9 @@ use super::{
|
||||
|
||||
// ---------------------------------------------------------------- tools
|
||||
|
||||
/// Most cells (rows times compared columns) one Remove Duplicates call reads.
|
||||
const MAX_COMPARED_CELLS: u64 = 50_000_000;
|
||||
|
||||
pub(super) fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let (sheet, r) = data_target(s, p, "data.removeDuplicates")?;
|
||||
let d = s.doc()?;
|
||||
@@ -24,6 +27,10 @@ pub(super) fn remove_duplicates(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if cols.is_empty() || cols.iter().any(|c| !(r.start.col..=r.end.col).contains(c)) {
|
||||
return Err(bad("data.removeDuplicates", "`columns` must name at least one column of the range"));
|
||||
}
|
||||
// Rows are compared one by one, so a sparse sheet's enclosing rectangle is bounded like a list.
|
||||
if r.count() > MAX_COMPARED_CELLS {
|
||||
return Err(bad("data.removeDuplicates", "the range is too large"));
|
||||
}
|
||||
let start = r.start.row + header as u32;
|
||||
let mut removed = 0;
|
||||
edit(s, |cx| {
|
||||
|
||||
@@ -337,11 +337,17 @@ pub(super) fn circle_invalid(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
let sheet = d.wb.active_sheet();
|
||||
let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) };
|
||||
let mut bad_cells = Vec::new();
|
||||
// One scan allowance for the whole command: duplicate or overlapping ranges don't multiply it.
|
||||
let mut budget = 100_000usize;
|
||||
// Cells were left unchecked because the allowance ran out: said in the result.
|
||||
let mut truncated = false;
|
||||
for dv in &sh.validations {
|
||||
for r in &dv.ranges {
|
||||
let r = sh.used_range().and_then(|u| r.intersection(&u));
|
||||
let Some(r) = r else { continue };
|
||||
for c in r.iter().take(100_000) {
|
||||
truncated |= r.count() > budget as u64;
|
||||
for c in r.iter().take(budget) {
|
||||
budget -= 1;
|
||||
let text = sh.input_text(c);
|
||||
if !text.is_empty() && check_validation(&d.wb, sheet, c, &text).is_some() {
|
||||
bad_cells.push(c.a1());
|
||||
@@ -349,5 +355,5 @@ pub(super) fn circle_invalid(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({"invalid": bad_cells}))
|
||||
Ok(json!({"invalid": bad_cells, "truncated": truncated}))
|
||||
}
|
||||
|
||||
@@ -239,7 +239,12 @@ fn redo(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
/// The rows of `range` an applied filter hides (rows hidden by hand are not among them).
|
||||
fn filtered_rows(sh: &gridcraft_model::Sheet, range: RangeRef) -> std::collections::BTreeSet<u32> {
|
||||
sh.rows.range(range.start.row..=range.end.row).filter(|(r, info)| info.hidden && sh.is_row_filtered(**r)).map(|(r, _)| *r).collect()
|
||||
let spans = sh.filter_row_spans();
|
||||
if spans.is_empty() {
|
||||
return Default::default();
|
||||
}
|
||||
let filtered = |row: u32| spans.iter().any(|&(first, last)| row >= first && row <= last);
|
||||
sh.rows.range(range.start.row..=range.end.row).filter(|(r, info)| info.hidden && filtered(**r)).map(|(r, _)| *r).collect()
|
||||
}
|
||||
|
||||
/// The Clear commands (Delete is Clear Contents). As in Excel, the rows an applied filter hides
|
||||
|
||||
@@ -328,7 +328,11 @@ pub(super) fn range_fill(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if r.count() > 2_000_000 {
|
||||
return Err(bad("range.fill", "range too large"));
|
||||
}
|
||||
let skip = cx.wb.sheet(sheet).map(|sh| super::filtered_rows(sh, *r)).unwrap_or_default();
|
||||
for at in r.iter() {
|
||||
if skip.contains(&at.row) {
|
||||
continue;
|
||||
}
|
||||
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(),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use super::cell::input_to_cell;
|
||||
use super::paste_special::paste_special;
|
||||
use super::{
|
||||
Cell, Clipboard, EngineError, Json, RangeRef, Result, Session, Value, bad, bool_param, cell_param_on, edit, json, ok, str_param, target_ranges,
|
||||
target_sheet,
|
||||
Cell, CellRef, Clipboard, EngineError, Json, RangeRef, Result, Session, Value, bad, bool_param, cell_param_on, edit, json, ok, str_param,
|
||||
target_ranges, target_sheet,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- clipboard
|
||||
@@ -15,7 +15,7 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
|
||||
return Err(multiple());
|
||||
}
|
||||
// The selection as made (untrimmed), for Insert Copied Cells.
|
||||
let selected = ranges.first().copied().unwrap_or_default();
|
||||
let mut selected = ranges.first().copied().unwrap_or_default();
|
||||
let d = s.doc()?;
|
||||
let Some(sh) = d.wb.sheet(sheet) else { return Err(EngineError::NoDocument) };
|
||||
let trim = |range: RangeRef| {
|
||||
@@ -31,7 +31,27 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
|
||||
let (wb, block) = slide_together(&d.wb, sheet, &areas).ok_or_else(multiple)?;
|
||||
(std::sync::Arc::new(wb), block, areas)
|
||||
} else {
|
||||
(d.wb.clone(), trim(ranges.first().copied().unwrap_or_default()), Vec::new())
|
||||
let range = trim(ranges.first().copied().unwrap_or_default());
|
||||
// A copied range with rows a filter hides takes only the rows showing: they are copied
|
||||
// like a multi-area selection stacked top to bottom (formulas as values), as in Excel.
|
||||
// A cut still takes every row.
|
||||
let hidden = if cut { Default::default() } else { super::filtered_rows(sh, range) };
|
||||
let visible = if hidden.is_empty() { Vec::new() } else { visible_row_runs(range, &hidden) };
|
||||
let any_visible = !visible.is_empty();
|
||||
let packed = match visible.as_slice() {
|
||||
[] => (d.wb.clone(), range, Vec::new()),
|
||||
[only] => (d.wb.clone(), *only, Vec::new()),
|
||||
_ => {
|
||||
let (wb, block) = slide_together(&d.wb, sheet, &visible).ok_or_else(multiple)?;
|
||||
(std::sync::Arc::new(wb), block, visible)
|
||||
}
|
||||
};
|
||||
// Whole rows copied with some hidden by a filter come as the block of rows showing, so
|
||||
// Insert Copied Cells makes room for those rows, not for the hidden ones too.
|
||||
if !hidden.is_empty() && any_visible && selected.is_full_rows() {
|
||||
selected = RangeRef::rows(packed.1.start.row, packed.1.end.row);
|
||||
}
|
||||
packed
|
||||
};
|
||||
let text = crate::display::range_text(&wb, sheet, range);
|
||||
// The HTML rendering is for the host clipboard (UI); it can reach 4 MB, so agents and
|
||||
@@ -46,6 +66,56 @@ fn copy_impl(s: &mut Session, p: &Json, cut: bool) -> Result<Json> {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// The runs of consecutive rows of `range` not in `hidden`, as ranges of `range`'s columns.
|
||||
fn visible_row_runs(range: RangeRef, hidden: &std::collections::BTreeSet<u32>) -> Vec<RangeRef> {
|
||||
let mut runs = Vec::new();
|
||||
let mut start = None;
|
||||
for row in range.start.row..=range.end.row {
|
||||
match (hidden.contains(&row), start) {
|
||||
(false, None) => start = Some(row),
|
||||
(true, Some(first)) => {
|
||||
runs.push(RangeRef::new(CellRef::new(first, range.start.col), CellRef::new(row - 1, range.end.col)));
|
||||
start = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(first) = start {
|
||||
runs.push(RangeRef::new(CellRef::new(first, range.start.col), CellRef::new(range.end.row, range.end.col)));
|
||||
}
|
||||
runs
|
||||
}
|
||||
|
||||
/// The validation rules of `src` cut into the areas of a packed block: one rule per area a rule
|
||||
/// touches, over that area's part moved by the area's shift. `sorted` areas run in order.
|
||||
fn pack_validations(src: &gridcraft_model::Sheet, sorted: &[RangeRef], shifts: &[(i64, i64)], across: bool) -> Vec<gridcraft_model::Validation> {
|
||||
let key = |c: CellRef| if across { c.col } else { c.row };
|
||||
let mut packed_rules = Vec::new();
|
||||
for dv in &src.validations {
|
||||
let Some(from) = dv.ranges.first().map(|r| r.start) else { continue };
|
||||
// The areas each of the rule's ranges touches lie side by side in the sorted list.
|
||||
let end_key = |a: &RangeRef| if across { a.end.col } else { a.end.row };
|
||||
let mut by_area: std::collections::BTreeMap<usize, Vec<RangeRef>> = Default::default();
|
||||
for r in &dv.ranges {
|
||||
let first = sorted.partition_point(|a| end_key(a) < key(r.start));
|
||||
for (i, a) in sorted.iter().enumerate().skip(first).take_while(|(_, a)| key(a.start) <= end_key(r)) {
|
||||
let (dr, dc) = shifts[i];
|
||||
let moved = r.intersection(a).and_then(|x| Some(RangeRef::new(x.start.offset(dr, dc)?, x.end.offset(dr, dc)?)));
|
||||
by_area.entry(i).or_default().extend(moved);
|
||||
}
|
||||
}
|
||||
for ranges in by_area.into_values() {
|
||||
let Some(to) = ranges.first().map(|r| r.start) else { continue };
|
||||
let mut rule = dv.clone();
|
||||
rule.f1 = super::super::data::move_rule_formula(&dv.f1, from, to);
|
||||
rule.f2 = dv.f2.as_ref().map(|f| super::super::data::move_rule_formula(f, from, to));
|
||||
rule.ranges = ranges;
|
||||
packed_rules.push(rule);
|
||||
}
|
||||
}
|
||||
packed_rules
|
||||
}
|
||||
|
||||
/// Excel copies a multi-area selection only when the areas line up: all cover the same rows (they
|
||||
/// slide together side by side, left to right) or all cover the same columns (stacked, top to
|
||||
/// bottom), without overlapping. Returns a copy of the workbook whose sheet holds the areas packed
|
||||
@@ -80,17 +150,35 @@ fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRe
|
||||
dst.spill.retain(|c, _| !block.contains(*c));
|
||||
dst.spill_ranges.retain(|c, _| !block.contains(*c));
|
||||
dst.comments.retain(|c, _| !block.contains(*c));
|
||||
dst.hyperlinks.retain(|c, _| !block.contains(*c));
|
||||
super::super::data::remove_validation_cells(&mut dst.validations, &[block]);
|
||||
dst.merges.retain(|m| !m.intersects(&block));
|
||||
if across {
|
||||
dst.cols.retain(|c, _| !(block.start.col..=block.end.col).contains(c));
|
||||
} else {
|
||||
dst.rows.retain(|r, _| !(block.start.row..=block.end.row).contains(r));
|
||||
}
|
||||
// Where each area lands: the shift of its cells, and its first row or column in the block.
|
||||
let key = |c: CellRef| if across { c.col } else { c.row };
|
||||
let mut next = if across { anchor.col } else { anchor.row };
|
||||
let mut shifts: Vec<(i64, i64)> = Vec::with_capacity(sorted.len());
|
||||
for a in &sorted {
|
||||
let (dr, dc) = if across { (0, i64::from(next) - i64::from(a.start.col)) } else { (i64::from(next) - i64::from(a.start.row), 0) };
|
||||
let cells = src.cells.iter_range(*a).map(|(c, _)| c).chain(src.spill.keys().copied().filter(|c| a.contains(*c)));
|
||||
for c in cells {
|
||||
let to = i64::from(next);
|
||||
shifts.push(if across { (0, to - i64::from(a.start.col)) } else { (to - i64::from(a.start.row), 0) });
|
||||
next = next.saturating_add(if across { a.width() } else { a.height() });
|
||||
}
|
||||
// The areas never overlap and run in order, so the area holding a cell is found by search:
|
||||
// each map below is read once, whatever the number of areas.
|
||||
let area_of = |c: CellRef| -> Option<usize> {
|
||||
let i = sorted.partition_point(|a| key(a.start) <= key(c)).checked_sub(1)?;
|
||||
sorted.get(i)?.contains(c).then_some(i)
|
||||
};
|
||||
let moved = |c: CellRef| -> Option<CellRef> {
|
||||
let (dr, dc) = shifts[area_of(c)?];
|
||||
c.offset(dr, dc)
|
||||
};
|
||||
for (a, &(dr, dc)) in sorted.iter().zip(&shifts) {
|
||||
for (c, _) in src.cells.iter_range(*a) {
|
||||
let Some(to) = c.offset(dr, dc) else { continue };
|
||||
let style = src.cell(c).map(|x| x.style).unwrap_or_default();
|
||||
dst.set_cell(to, Cell { value: src.value(c), formula: None, style, py: None });
|
||||
@@ -98,17 +186,38 @@ fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRe
|
||||
dst.cell_pictures.insert(to, picture.clone());
|
||||
}
|
||||
}
|
||||
for (c, comment) in src.comments.iter().filter(|(c, _)| a.contains(**c)) {
|
||||
if let Some(to) = c.offset(dr, dc) {
|
||||
dst.comments.insert(to, comment.clone());
|
||||
}
|
||||
}
|
||||
for c in src.spill.keys().copied() {
|
||||
let Some(to) = moved(c) else { continue };
|
||||
let style = src.cell(c).map(|x| x.style).unwrap_or_default();
|
||||
dst.set_cell(to, Cell { value: src.value(c), formula: None, style, py: None });
|
||||
if let Some(picture) = src.cell_pictures.get(&c) {
|
||||
dst.cell_pictures.insert(to, picture.clone());
|
||||
}
|
||||
for m in src.merges.iter().filter(|m| a.contains_range(m)) {
|
||||
if let (Some(s), Some(e)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) {
|
||||
dst.merges.push(RangeRef::new(s, e));
|
||||
}
|
||||
}
|
||||
// Links and validation rules follow their cells too, so a row the filter hides leaves its own
|
||||
// behind.
|
||||
for (c, link) in &src.hyperlinks {
|
||||
if let Some(to) = moved(*c) {
|
||||
dst.hyperlinks.insert(to, link.clone());
|
||||
}
|
||||
// Column widths (side by side) or row heights and hidden rows (stacked) follow their cells.
|
||||
}
|
||||
let packed_rules = pack_validations(src, &sorted, &shifts, across);
|
||||
for (c, comment) in &src.comments {
|
||||
if let Some(to) = moved(*c) {
|
||||
dst.comments.insert(to, comment.clone());
|
||||
}
|
||||
}
|
||||
for m in &src.merges {
|
||||
let Some(i) = area_of(m.start).filter(|&i| sorted[i].contains_range(m)) else { continue };
|
||||
let (dr, dc) = shifts[i];
|
||||
if let (Some(s), Some(e)) = (m.start.offset(dr, dc), m.end.offset(dr, dc)) {
|
||||
dst.merges.push(RangeRef::new(s, e));
|
||||
}
|
||||
}
|
||||
// Column widths (side by side) or row heights and hidden rows (stacked) follow their cells.
|
||||
let mut next = if across { anchor.col } else { anchor.row };
|
||||
for a in &sorted {
|
||||
if across {
|
||||
for (c, info) in src.cols.range(a.start.col..=a.end.col) {
|
||||
dst.cols.insert(next.saturating_add(c.saturating_sub(a.start.col)), *info);
|
||||
@@ -120,6 +229,7 @@ fn slide_together(wb: &gridcraft_model::Workbook, sheet: usize, areas: &[RangeRe
|
||||
}
|
||||
next = next.saturating_add(if across { a.width() } else { a.height() });
|
||||
}
|
||||
dst.validations.extend(packed_rules);
|
||||
Some((out, block))
|
||||
}
|
||||
|
||||
|
||||
@@ -31,8 +31,9 @@ pub(super) fn fill_dir(s: &mut Session, p: &Json, dr: i64, dc: i64) -> Result<Js
|
||||
} else {
|
||||
RangeRef::new(CellRef::new(r.start.row, r.end.col), r.end)
|
||||
};
|
||||
let skip = s.doc()?.wb.sheet(sheet).map(|sh| super::filtered_rows(sh, r)).unwrap_or_default();
|
||||
edit(s, |cx| {
|
||||
crate::fill::fill(cx, sheet, src, r, crate::fill::FillMode::Copy)?;
|
||||
crate::fill::fill_except(cx, sheet, src, r, crate::fill::FillMode::Copy, &skip)?;
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,8 +14,13 @@ fn matches(hay: &str, what: &str, case: bool, whole: bool) -> bool {
|
||||
}
|
||||
|
||||
/// `*`/`?` wildcard match with `~` escapes.
|
||||
///
|
||||
/// The pattern runs as a bit-parallel automaton over the text: one pass, each character costing
|
||||
/// one step per 64 pattern positions. Backtracking to the last `*` after a mismatch rescans the
|
||||
/// text and costs text × pattern comparisons on a long, repetitive cell, for every cell a
|
||||
/// filter or Find visits.
|
||||
pub(crate) fn wildcard(text: &str, pat: &str) -> bool {
|
||||
let t: Vec<char> = text.chars().collect();
|
||||
// The pattern as (character, escaped) items, runs of `*` folded into one.
|
||||
let mut p: Vec<(char, bool)> = Vec::new();
|
||||
let mut it = pat.chars();
|
||||
while let Some(c) = it.next() {
|
||||
@@ -23,32 +28,85 @@ pub(crate) fn wildcard(text: &str, pat: &str) -> bool {
|
||||
if let Some(n) = it.next() {
|
||||
p.push((n, true));
|
||||
}
|
||||
} else {
|
||||
} else if c != '*' || p.last() != Some(&('*', false)) {
|
||||
p.push((c, false));
|
||||
}
|
||||
}
|
||||
let (mut ti, mut pi) = (0usize, 0usize);
|
||||
let (mut star, mut mark) = (None, 0usize);
|
||||
while ti < t.len() {
|
||||
if pi < p.len() && (p[pi] == ('?', false) || (p[pi].0 == t[ti] && p[pi] != ('*', false))) {
|
||||
ti += 1;
|
||||
pi += 1;
|
||||
} else if pi < p.len() && p[pi] == ('*', false) {
|
||||
star = Some(pi);
|
||||
mark = ti;
|
||||
pi += 1;
|
||||
} else if let Some(sp) = star {
|
||||
pi = sp + 1;
|
||||
mark += 1;
|
||||
ti = mark;
|
||||
} else {
|
||||
return false;
|
||||
let words = (p.len() + 1).div_ceil(64);
|
||||
let bit = |set: &mut [u64], i: usize| set[i / 64] |= 1 << (i % 64);
|
||||
// Positions holding a `*` and a `?`, and where each literal character sits in the pattern.
|
||||
let mut stars = vec![0u64; words];
|
||||
let mut any = vec![0u64; words];
|
||||
let mut at: std::collections::HashMap<char, Vec<usize>> = std::collections::HashMap::new();
|
||||
for (i, item) in p.iter().enumerate() {
|
||||
match item {
|
||||
('*', false) => bit(&mut stars, i),
|
||||
('?', false) => bit(&mut any, i),
|
||||
(c, _) => at.entry(*c).or_default().push(i),
|
||||
}
|
||||
}
|
||||
while pi < p.len() && p[pi] == ('*', false) {
|
||||
pi += 1;
|
||||
// A literal's bit mask is built when the text first reads it, and kept for at most
|
||||
// `MAX_CACHED_MASKS` literals: a long pattern of mostly distinct characters would otherwise
|
||||
// allocate a mask of the pattern's width for each one up front. When there are more literals
|
||||
// than that, the ones kept are those at the most pattern positions (the dearest to rebuild);
|
||||
// each of the rest has at most pattern length / `MAX_CACHED_MASKS` positions, so rebuilding
|
||||
// its mask in `scratch` costs less than one step of the scan.
|
||||
const MAX_CACHED_MASKS: usize = 256;
|
||||
let cacheable: Option<std::collections::HashSet<char>> = (at.len() > MAX_CACHED_MASKS).then(|| {
|
||||
let mut by_count: Vec<(&char, usize)> = at.iter().map(|(c, v)| (c, v.len())).collect();
|
||||
by_count.sort_unstable_by_key(|&(_, n)| std::cmp::Reverse(n));
|
||||
by_count.into_iter().take(MAX_CACHED_MASKS).map(|(c, _)| *c).collect()
|
||||
});
|
||||
let mut masks: std::collections::HashMap<char, Vec<u64>> = std::collections::HashMap::new();
|
||||
let mut scratch = vec![0u64; words];
|
||||
// A set bit i: the text read so far matches the first i pattern items. A `*` matches nothing
|
||||
// as well, so a state at a `*` is also at the item after it.
|
||||
let close = |set: &mut Vec<u64>| {
|
||||
let mut carry = 0u64;
|
||||
for w in 0..words {
|
||||
let s = set[w] & stars[w];
|
||||
let next = (s << 1) | carry;
|
||||
carry = s >> 63;
|
||||
set[w] |= next;
|
||||
}
|
||||
};
|
||||
let mut state = vec![0u64; words];
|
||||
bit(&mut state, 0);
|
||||
close(&mut state);
|
||||
let mut next = vec![0u64; words];
|
||||
for ch in text.chars() {
|
||||
let lit: Option<&[u64]> = match at.get(&ch) {
|
||||
None => None,
|
||||
Some(positions) if cacheable.as_ref().is_none_or(|k| k.contains(&ch)) => {
|
||||
let mask = masks.entry(ch).or_insert_with(|| {
|
||||
let mut m = vec![0u64; words];
|
||||
positions.iter().for_each(|&i| bit(&mut m, i));
|
||||
m
|
||||
});
|
||||
Some(mask)
|
||||
}
|
||||
Some(positions) => {
|
||||
scratch.fill(0);
|
||||
positions.iter().for_each(|&i| bit(&mut scratch, i));
|
||||
Some(&scratch)
|
||||
}
|
||||
};
|
||||
let mut carry = 0u64;
|
||||
let mut alive = 0u64;
|
||||
for w in 0..words {
|
||||
// Items that read `ch` advance; a `*` reads it and stays.
|
||||
let m = state[w] & (any[w] | lit.map_or(0, |l| l[w]));
|
||||
next[w] = (m << 1) | carry | (state[w] & stars[w]);
|
||||
carry = m >> 63;
|
||||
alive |= next[w];
|
||||
}
|
||||
if alive == 0 {
|
||||
return false;
|
||||
}
|
||||
std::mem::swap(&mut state, &mut next);
|
||||
close(&mut state);
|
||||
}
|
||||
pi == p.len()
|
||||
state[p.len() / 64] & (1 << (p.len() % 64)) != 0
|
||||
}
|
||||
|
||||
pub(super) fn find(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
@@ -112,9 +170,19 @@ pub(super) fn find(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Ok(json!({"cell": c.a1(), "sheet": si, "value": t, "count": hits.len()}))
|
||||
}
|
||||
|
||||
/// Most characters a cell holds (Excel's limit).
|
||||
const REPLACE_CELL_MAX_CHARS: usize = 32_767;
|
||||
/// Longest text one replacement may give a cell, in bytes: the character limit, each up to four
|
||||
/// bytes. Every occurrence copies the replacement text, so a short cell full of matches and a
|
||||
/// long replacement would otherwise expand to gigabytes. The character limit is checked on what
|
||||
/// fits in this.
|
||||
const REPLACE_CELL_MAX_BYTES: usize = REPLACE_CELL_MAX_CHARS * 4;
|
||||
/// Most bytes of replaced text one Replace All builds across all its cells.
|
||||
const REPLACE_TOTAL_MAX_BYTES: usize = 256 * 1024 * 1024;
|
||||
|
||||
pub(super) fn replace(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let what = str_param(p, "what").unwrap_or("").to_string();
|
||||
let with = str_param(p, "with").unwrap_or("").to_string();
|
||||
let with = str_param(p, "with").unwrap_or("");
|
||||
if what.is_empty() {
|
||||
return Err(bad("edit.replace", "missing `what`"));
|
||||
}
|
||||
@@ -128,9 +196,14 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let n = edit(s, |cx| {
|
||||
let sheets: Vec<usize> = if workbook { (0..cx.wb.sheets.len()).collect() } else { vec![cx.wb.active_sheet()] };
|
||||
let mut n = 0;
|
||||
let mut total = 0usize;
|
||||
for si in sheets {
|
||||
let cells: Vec<(CellRef, String)> =
|
||||
cx.wb.sheet(si).map(|sh| sh.cells.iter().map(|(c, _)| (c, sh.input_text(c))).collect()).unwrap_or_default();
|
||||
// A hidden formula's source is not searched: a count or error would reveal its text.
|
||||
cx.wb
|
||||
.sheet(si)
|
||||
.map(|sh| sh.cells.iter().filter(|(c, cell)| cell.formula.is_none() && cell.py.is_none() || !crate::display::formula_hidden(&cx.wb, sh, *c)).map(|(c, _)| (c, sh.input_text(c))).collect())
|
||||
.unwrap_or_default();
|
||||
for (c, text) in cells {
|
||||
if (!all && c != active) || selection.as_ref().is_some_and(|sel| !sel.contains(c)) {
|
||||
continue;
|
||||
@@ -139,12 +212,20 @@ pub(super) fn replace(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
continue;
|
||||
}
|
||||
let new = if whole {
|
||||
with.clone()
|
||||
(with.len() <= REPLACE_CELL_MAX_BYTES).then(|| with.to_string())
|
||||
} else if case {
|
||||
text.replace(&what, &with)
|
||||
replace_cs_within(&text, &what, with, REPLACE_CELL_MAX_BYTES)
|
||||
} else {
|
||||
replace_ci(&text, &what, &with)
|
||||
replace_ci_within(&text, &what, with, REPLACE_CELL_MAX_BYTES)
|
||||
};
|
||||
let new = new.filter(|n| n.chars().count() <= REPLACE_CELL_MAX_CHARS);
|
||||
let Some(new) = new else {
|
||||
return Err(EngineError::Other(format!("The replacement makes the text of {} too long.", c.a1())));
|
||||
};
|
||||
total = total.saturating_add(new.len());
|
||||
if total > REPLACE_TOTAL_MAX_BYTES {
|
||||
return Err(EngineError::Other("The replacements make too much text. Replace in a smaller range.".into()));
|
||||
}
|
||||
// A Python cell's text is its code: the replacement edits the code and the cell
|
||||
// stays a Python cell. Code that would no longer fit in a PY formula is left as it was.
|
||||
let old_py = cx.wb.sheet(si).and_then(|sh| sh.cell(c)).and_then(|x| x.py.clone());
|
||||
@@ -199,7 +280,37 @@ fn folds_in_place(s: &str) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn replace_ci(text: &str, what: &str, with: &str) -> String {
|
||||
replace_ci_within(text, what, with, usize::MAX).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `text` with every `what` replaced by `with`, or `None` once the result passes `limit` bytes
|
||||
/// (checked before each copy, so nothing larger is allocated).
|
||||
pub(super) fn replace_cs_within(text: &str, what: &str, with: &str, limit: usize) -> Option<String> {
|
||||
if what.is_empty() {
|
||||
return (text.len() <= limit).then(|| text.to_string());
|
||||
}
|
||||
let mut out = String::new();
|
||||
let mut i = 0;
|
||||
for (pos, m) in text.match_indices(what) {
|
||||
if out.len() + (pos - i) + with.len() > limit {
|
||||
return None;
|
||||
}
|
||||
out.push_str(text.get(i..pos).unwrap_or(""));
|
||||
out.push_str(with);
|
||||
i = pos + m.len();
|
||||
}
|
||||
let rest = text.get(i..).unwrap_or("");
|
||||
if out.len() + rest.len() > limit {
|
||||
return None;
|
||||
}
|
||||
out.push_str(rest);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Case-insensitive [`replace_cs_within`].
|
||||
pub(super) fn replace_ci_within(text: &str, what: &str, with: &str, limit: usize) -> Option<String> {
|
||||
let lower = text.to_lowercase();
|
||||
let w = what.to_lowercase();
|
||||
// Slicing `text` at positions found in `lower` is only sound when folding kept every byte
|
||||
@@ -207,15 +318,22 @@ pub(super) fn replace_ci(text: &str, what: &str, with: &str) -> String {
|
||||
// boundaries (U+212A KELVIN SIGN shrinks 3→1 bytes, U+0130 grows 2→3), where the tail used
|
||||
// to be dropped. Those fall back to the case-sensitive replace, as length changes already do.
|
||||
if lower.len() != text.len() || w.is_empty() || !folds_in_place(text) || !folds_in_place(what) {
|
||||
return text.replace(what, with);
|
||||
return replace_cs_within(text, what, with, limit);
|
||||
}
|
||||
let mut out = String::new();
|
||||
let mut i = 0;
|
||||
while let Some(pos) = lower.get(i..).and_then(|s| s.find(&w)) {
|
||||
if out.len() + pos + with.len() > limit {
|
||||
return None;
|
||||
}
|
||||
out.push_str(text.get(i..i + pos).unwrap_or(""));
|
||||
out.push_str(with);
|
||||
i += pos + w.len();
|
||||
}
|
||||
out.push_str(text.get(i..).unwrap_or(""));
|
||||
out
|
||||
let rest = text.get(i..).unwrap_or("");
|
||||
if out.len() + rest.len() > limit {
|
||||
return None;
|
||||
}
|
||||
out.push_str(rest);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
@@ -17,11 +17,13 @@ pub(in super::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`")) };
|
||||
let d = s.doc()?;
|
||||
let mut names = Resolver::new(&d.wb);
|
||||
let mut target: Option<usize> = None;
|
||||
let mut areas: Vec<RangeRef> = Vec::new();
|
||||
let mut named = false;
|
||||
for part in reference_parts(reference) {
|
||||
let Some((sheet, found, by_name)) = go_to_target(&d.wb, d.wb.active_sheet(), part, 0) else {
|
||||
let mut found = Vec::new();
|
||||
let Some((sheet, _, by_name)) = names.target(d.wb.active_sheet(), part, 0, Some(&mut found)) else {
|
||||
return Err(EngineError::Other("Reference isn't valid.".into()));
|
||||
};
|
||||
// One selection holds the areas of one sheet.
|
||||
@@ -77,57 +79,114 @@ pub(super) fn reference_parts(text: &str) -> impl Iterator<Item = &str> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Where one part of a Go To reference points, seen from sheet `sheet`: the sheet, its areas,
|
||||
/// and whether a defined name or a table gave them. `Sheet2!Name` is the name scoped to Sheet2.
|
||||
/// A name counts when what it refers to is a reference; a constant or a formula is not one.
|
||||
fn go_to_target(wb: &gridcraft_model::Workbook, sheet: usize, part: &str, depth: usize) -> Option<(usize, Vec<RangeRef>, bool)> {
|
||||
if part.is_empty() || depth > GO_TO_NAME_DEPTH {
|
||||
return None;
|
||||
}
|
||||
let (prefix, body) = split_sheet(part);
|
||||
let on = match &prefix {
|
||||
Some(n) => wb.sheet_index(n)?,
|
||||
None => sheet,
|
||||
};
|
||||
if let Some(r) = RangeRef::parse(&body.replace('$', "")) {
|
||||
return Some((on, vec![r], false));
|
||||
}
|
||||
let name = match prefix {
|
||||
Some(_) => wb.names.iter().find(|n| n.scope == Some(on) && gridcraft_formula::same_name(&n.name, body)),
|
||||
None => wb.name(body, sheet),
|
||||
};
|
||||
if let Some(n) = name {
|
||||
return name_areas(wb, n, sheet, depth).map(|(t, areas)| (t, areas, true));
|
||||
}
|
||||
if prefix.is_none()
|
||||
&& let Some((si, ti)) = wb.table(body)
|
||||
{
|
||||
// As in Excel, a table's name stands for its data, without the header and totals rows.
|
||||
let t = wb.sheet(si)?.tables.get(ti)?;
|
||||
return Some((si, vec![t.data_range().unwrap_or(t.range)], true));
|
||||
}
|
||||
None
|
||||
/// Resolves Go To references against one workbook. Defined names are found through an index
|
||||
/// built once, and whether a name refers to a reference is remembered: listing the names of a
|
||||
/// workbook full of names that alias other names would otherwise scan every name for every alias
|
||||
/// of every name.
|
||||
struct Resolver<'a> {
|
||||
wb: &'a gridcraft_model::Workbook,
|
||||
/// Indexes into `wb.names` by scope and folded name, in workbook order.
|
||||
by_name: std::collections::HashMap<(Option<usize>, String), Vec<usize>>,
|
||||
/// [`Resolver::name_areas`] without areas, by name index, sheet and depth: the target sheet
|
||||
/// and the number of areas.
|
||||
memo: std::collections::HashMap<(usize, usize, usize), Option<(usize, usize)>>,
|
||||
}
|
||||
|
||||
/// The sheet and areas defined name `n` refers to, seen from sheet `sheet`, when that is a
|
||||
/// reference.
|
||||
fn name_areas(wb: &gridcraft_model::Workbook, n: &gridcraft_model::DefinedName, sheet: usize, depth: usize) -> Option<(usize, Vec<RangeRef>)> {
|
||||
let from = n.scope.unwrap_or(sheet);
|
||||
let formula = n.formula.trim().trim_start_matches('=');
|
||||
let mut target: Option<usize> = None;
|
||||
let mut areas = Vec::new();
|
||||
for p in reference_parts(formula) {
|
||||
let (s, found, _) = go_to_target(wb, from, p, depth + 1)?;
|
||||
if target.is_some_and(|t| t != s) {
|
||||
return None;
|
||||
}
|
||||
target = Some(s);
|
||||
areas.extend(found);
|
||||
if areas.len() > GO_TO_MAX_AREAS {
|
||||
return None;
|
||||
impl<'a> Resolver<'a> {
|
||||
fn new(wb: &'a gridcraft_model::Workbook) -> Self {
|
||||
let mut by_name: std::collections::HashMap<(Option<usize>, String), Vec<usize>> = std::collections::HashMap::new();
|
||||
for (i, n) in wb.names.iter().enumerate() {
|
||||
by_name.entry((n.scope, gridcraft_formula::fold_name(&n.name))).or_default().push(i);
|
||||
}
|
||||
Self { wb, by_name, memo: std::collections::HashMap::new() }
|
||||
}
|
||||
|
||||
/// The first name of scope `scope` that `same` accepts, as a scan of `wb.names` finds it.
|
||||
/// Names `same` accepts fold alike, so only that bucket is checked.
|
||||
fn find(&self, scope: Option<usize>, body: &str, same: impl Fn(&str, &str) -> bool) -> Option<usize> {
|
||||
self.by_name
|
||||
.get(&(scope, gridcraft_formula::fold_name(body)))?
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|&i| self.wb.names.get(i).is_some_and(|n| same(&n.name, body)))
|
||||
}
|
||||
|
||||
/// Where one part of a Go To reference points, seen from sheet `sheet`: the sheet, how many
|
||||
/// areas (appended to `areas` when given), and whether a defined name or a table gave them.
|
||||
/// `Sheet2!Name` is the name scoped to Sheet2. A name counts when what it refers to is a
|
||||
/// reference; a constant or a formula is not one.
|
||||
fn target(&mut self, sheet: usize, part: &str, depth: usize, areas: Option<&mut Vec<RangeRef>>) -> Option<(usize, usize, bool)> {
|
||||
let wb = self.wb;
|
||||
if part.is_empty() || depth > GO_TO_NAME_DEPTH {
|
||||
return None;
|
||||
}
|
||||
let (prefix, body) = split_sheet(part);
|
||||
let on = match &prefix {
|
||||
Some(n) => wb.sheet_index(n)?,
|
||||
None => sheet,
|
||||
};
|
||||
if let Some(r) = RangeRef::parse(&body.replace('$', "")) {
|
||||
if let Some(a) = areas {
|
||||
a.push(r);
|
||||
}
|
||||
return Some((on, 1, false));
|
||||
}
|
||||
// As `Workbook::name`: the sheet's own name, else the workbook's.
|
||||
let name = match prefix {
|
||||
Some(_) => self.find(Some(on), body, gridcraft_formula::same_name),
|
||||
None => self.find(Some(sheet), body, str::eq_ignore_ascii_case).or_else(|| self.find(None, body, str::eq_ignore_ascii_case)),
|
||||
};
|
||||
if let Some(i) = name {
|
||||
return self.name_areas(i, sheet, depth, areas).map(|(t, n)| (t, n, true));
|
||||
}
|
||||
if prefix.is_none()
|
||||
&& let Some((si, ti)) = wb.table(body)
|
||||
{
|
||||
// As in Excel, a table's name stands for its data, without the header and totals rows.
|
||||
let t = wb.sheet(si)?.tables.get(ti)?;
|
||||
if let Some(a) = areas {
|
||||
a.push(t.data_range().unwrap_or(t.range));
|
||||
}
|
||||
return Some((si, 1, true));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The sheet and number of areas defined name `wb.names[i]` refers to, seen from sheet
|
||||
/// `sheet`, when that is a reference; the areas are appended to `areas` when given.
|
||||
fn name_areas(&mut self, i: usize, sheet: usize, depth: usize, mut areas: Option<&mut Vec<RangeRef>>) -> Option<(usize, usize)> {
|
||||
let key = (i, sheet, depth);
|
||||
if areas.is_none()
|
||||
&& let Some(known) = self.memo.get(&key)
|
||||
{
|
||||
return *known;
|
||||
}
|
||||
let out = self.name_areas_uncached(i, sheet, depth, areas.as_deref_mut());
|
||||
if areas.is_none() {
|
||||
self.memo.insert(key, out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn name_areas_uncached(&mut self, i: usize, sheet: usize, depth: usize, mut areas: Option<&mut Vec<RangeRef>>) -> Option<(usize, usize)> {
|
||||
let n = self.wb.names.get(i)?;
|
||||
let from = n.scope.unwrap_or(sheet);
|
||||
let formula = n.formula.trim().trim_start_matches('=');
|
||||
let mut target: Option<usize> = None;
|
||||
let mut count = 0usize;
|
||||
for p in reference_parts(formula) {
|
||||
let (s, found, _) = self.target(from, p, depth + 1, areas.as_deref_mut())?;
|
||||
if target.is_some_and(|t| t != s) {
|
||||
return None;
|
||||
}
|
||||
target = Some(s);
|
||||
count += found;
|
||||
if count > GO_TO_MAX_AREAS {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
target.map(|t| (t, count))
|
||||
}
|
||||
target.map(|t| (t, areas))
|
||||
}
|
||||
|
||||
/// The names the Go To dialog lists, seen from sheet `sheet`: the named ranges of the workbook and
|
||||
@@ -137,11 +196,13 @@ fn name_areas(wb: &gridcraft_model::Workbook, n: &gridcraft_model::DefinedName,
|
||||
pub fn go_to_names(wb: &gridcraft_model::Workbook, sheet: usize) -> Vec<String> {
|
||||
let own: std::collections::HashSet<String> =
|
||||
wb.names.iter().filter(|n| n.scope == Some(sheet)).map(|n| gridcraft_formula::fold_name(&n.name)).collect();
|
||||
let mut names = Resolver::new(wb);
|
||||
let mut out: Vec<String> = wb
|
||||
.names
|
||||
.iter()
|
||||
.filter(|n| !n.hidden && name_areas(wb, n, sheet, 0).is_some())
|
||||
.filter_map(|n| match n.scope {
|
||||
.enumerate()
|
||||
.filter(|(i, n)| !n.hidden && names.name_areas(*i, sheet, 0, None).is_some())
|
||||
.filter_map(|(_, n)| match n.scope {
|
||||
None if own.contains(&gridcraft_formula::fold_name(&n.name)) => None,
|
||||
None => Some(n.name.clone()),
|
||||
Some(s) if s == sheet => Some(n.name.clone()),
|
||||
|
||||
@@ -45,6 +45,12 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
&& !sel.is_full_rows()
|
||||
&& sel.count() <= 4_000_000;
|
||||
let (tiles_r, tiles_c) = if tile { (sel.height() / h, sel.width() / w) } else { (1, 1) };
|
||||
// A single cell or row repeated down a filtered selection skips the rows the filter hides; a
|
||||
// taller block is pasted straight down, hidden rows included, as in Excel.
|
||||
let skip_rows = match s.doc()?.wb.sheet(dest_sheet) {
|
||||
Some(sh) if tile && h == 1 => super::filtered_rows(sh, sel),
|
||||
_ => Default::default(),
|
||||
};
|
||||
// Excel refuses a paste that would run off the sheet (whole columns pasted below row 1, whole
|
||||
// rows right of column A, a whole column transposed) instead of pasting part of it.
|
||||
if at.offset(i64::from(tiles_r * h) - 1, i64::from(tiles_c * w) - 1).is_none() {
|
||||
@@ -134,6 +140,9 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let sc = CellRef::new(src.start.row + r, src.start.col + c);
|
||||
let (dr, dc) = if transpose { (c, r) } else { (r, c) };
|
||||
let Some(dest) = at.offset((tr * h + dr) as i64, (tc * w + dc) as i64) else { continue };
|
||||
if skip_rows.contains(&dest.row) {
|
||||
continue;
|
||||
}
|
||||
let src_cell = if cut_move { moved_formulas.get(&sc).cloned() } else { src_sheet.cell(sc).cloned() };
|
||||
let src_val = src_sheet.value(sc);
|
||||
let src_picture = if cut_move { moved_pictures.get(&sc) } else { src_sheet.cell_pictures.get(&sc) }.cloned();
|
||||
@@ -326,7 +335,7 @@ pub(super) fn paste_special(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
let pasted = RangeRef::new(at, at.offset_clamped(i64::from(tiles_r * h) - 1, i64::from(tiles_c * w) - 1));
|
||||
if !link && matches!(what, What::All | What::AllExceptBorders | What::Validation) {
|
||||
paste_validations(cx, src_sheet, src, dest_sheet, pasted, transpose, skip_blanks)?;
|
||||
paste_validations(cx, src_sheet, src, dest_sheet, pasted, PasteMode { transpose, skip_blanks, skip_rows: &skip_rows })?;
|
||||
}
|
||||
if what == What::ColumnWidths {
|
||||
// Each pasted column takes the width of the column copied to it (transposed, the
|
||||
@@ -394,6 +403,31 @@ const MAX_PASTE_LINK_CELLS: u64 = 1_048_576;
|
||||
/// selection makes one range per tile).
|
||||
const MAX_PASTE_VALIDATION_RANGES: usize = 100_000;
|
||||
|
||||
/// How a validation paste lays the rules out: transposed or not, keeping the destination's rule under
|
||||
/// blank source cells, and leaving the rows a filter hides as they are.
|
||||
#[derive(Clone, Copy)]
|
||||
struct PasteMode<'a> {
|
||||
transpose: bool,
|
||||
skip_blanks: bool,
|
||||
skip_rows: &'a std::collections::BTreeSet<u32>,
|
||||
}
|
||||
|
||||
/// `range` less the rows in `skip`: the runs of rows between them, as ranges of its columns.
|
||||
fn without_rows(range: RangeRef, skip: &std::collections::BTreeSet<u32>) -> Vec<RangeRef> {
|
||||
let mut out = Vec::new();
|
||||
let mut next = range.start.row;
|
||||
for &row in skip.range(range.start.row..=range.end.row) {
|
||||
if row > next {
|
||||
out.push(RangeRef::new(CellRef::new(next, range.start.col), CellRef::new(row - 1, range.end.col)));
|
||||
}
|
||||
next = row + 1;
|
||||
}
|
||||
if next <= range.end.row {
|
||||
out.push(RangeRef::new(CellRef::new(next, range.start.col), CellRef::new(range.end.row, range.end.col)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Pasting validation works on ranges, as Excel stores it: the pasted rectangle is cut out of the
|
||||
/// destination's rules (cells whose source had no rule end up with none), then each source rule
|
||||
/// that covers part of the copied block `src` comes along as one rule over the translated parts.
|
||||
@@ -404,9 +438,9 @@ fn paste_validations(
|
||||
src: RangeRef,
|
||||
dest_sheet: usize,
|
||||
pasted: RangeRef,
|
||||
transpose: bool,
|
||||
skip_blanks: bool,
|
||||
mode: PasteMode<'_>,
|
||||
) -> Result<()> {
|
||||
let PasteMode { transpose, skip_blanks, skip_rows } = mode;
|
||||
let (h, w) = if transpose { (src.width(), src.height()) } else { (src.height(), src.width()) };
|
||||
let (tiles_r, tiles_c) = (pasted.height() / h.max(1), pasted.width() / w.max(1));
|
||||
let mut rules = Vec::new();
|
||||
@@ -426,7 +460,9 @@ fn paste_validations(
|
||||
let (r, c) = if transpose { (c, r) } else { (r, c) };
|
||||
for tr in 0..tiles_r {
|
||||
for tc in 0..tiles_c {
|
||||
if let Some(d) = pasted.start.offset(i64::from(tr * h + r), i64::from(tc * w + c)) {
|
||||
if let Some(d) = pasted.start.offset(i64::from(tr * h + r), i64::from(tc * w + c))
|
||||
&& !skip_rows.contains(&d.row)
|
||||
{
|
||||
cells.push((d.row, d.col));
|
||||
}
|
||||
}
|
||||
@@ -490,6 +526,8 @@ fn paste_validations(
|
||||
}
|
||||
}
|
||||
}
|
||||
// The rows a filter hides keep what they have.
|
||||
let ranges: Vec<RangeRef> = ranges.into_iter().flat_map(|r| without_rows(r, skip_rows)).collect();
|
||||
let Some(anchor) = ranges.first().map(|r| r.start) else { continue };
|
||||
let mut rule = dv.clone();
|
||||
// Relative references move with the rule, as pasted formulas do: they are written for the
|
||||
@@ -507,7 +545,7 @@ fn paste_validations(
|
||||
cleared.extend(rules.iter().flat_map(|r| r.ranges.iter().copied()));
|
||||
super::super::data::remove_validation_cells(&mut sh.validations, &cleared);
|
||||
} else {
|
||||
super::super::data::remove_validation_cells(&mut sh.validations, &[pasted]);
|
||||
super::super::data::remove_validation_cells(&mut sh.validations, &without_rows(pasted, skip_rows));
|
||||
}
|
||||
sh.validations.extend(rules);
|
||||
Ok(())
|
||||
@@ -529,19 +567,19 @@ pub(crate) fn rewrite_all_formulas(wb: &mut gridcraft_model::Workbook, target: &
|
||||
let on_target = moves_arrays && host.eq_ignore_ascii_case(target);
|
||||
let Some(sheet) = wb.sheet_mut(si) else { continue };
|
||||
for (c, f) in keys {
|
||||
let Some(expr) = f.expr() else { continue };
|
||||
let new = gridcraft_formula::adjust::adjust(expr, &host, target, e);
|
||||
let text = gridcraft_formula::print(&new);
|
||||
// Borrows the tree and copies and prints only the formulas the edit changes. A formula
|
||||
// the edit leaves alone keeps its stored text, even if that text is not canonical.
|
||||
let Some(new) = f.with_expr(|x| gridcraft_formula::adjust::adjust_if_changed(x, &host, target, e)) else { continue };
|
||||
let array = match f.array {
|
||||
Some(a) if on_target => crate::cmd::sheet::map_range(a, e),
|
||||
a => a,
|
||||
};
|
||||
if (text != f.text || array != f.array)
|
||||
&& let Some(cell) = sheet.cells.get_mut(c)
|
||||
{
|
||||
let mut nf = Formula::from_expr(new);
|
||||
nf.array = array;
|
||||
nf.dynamic = f.dynamic;
|
||||
let nf = match new {
|
||||
Some(new) => Formula { array, dynamic: f.dynamic, ..Formula::from_expr(new) },
|
||||
None if array != f.array => Formula { array, ..(*f).clone() },
|
||||
None => continue,
|
||||
};
|
||||
if let Some(cell) = sheet.cells.get_mut(c) {
|
||||
cell.formula = Some(std::sync::Arc::new(nf));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,14 @@ pub(super) fn selection_move(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
// Extension moves the far corner, not the active cell.
|
||||
let from = if extend { sel.moving_end() } else { sel.active };
|
||||
// Skip hidden rows/columns and step over merged areas.
|
||||
let mut to = if jmp {
|
||||
let mut to = if jmp && extend && (dr == 0) != (dc == 0) {
|
||||
// Excel: Ctrl+Shift+arrow searches the active cell's column (or row) from the moving
|
||||
// edge, so a header row selected with Ctrl+Shift+Right extends down to the end of the
|
||||
// active column's data.
|
||||
let probe = if dr != 0 { CellRef::new(from.row, sel.active.col) } else { CellRef::new(sel.active.row, from.col) };
|
||||
let edge = jump(sheet, probe, dr.signum(), dc.signum());
|
||||
if dr != 0 { CellRef::new(edge.row, from.col) } else { CellRef::new(from.row, edge.col) }
|
||||
} else if jmp {
|
||||
jump(sheet, from, dr.signum(), dc.signum())
|
||||
} else {
|
||||
let mut t = from.offset_clamped(dr, dc);
|
||||
|
||||
@@ -42,6 +42,28 @@ fn go_to_lists_the_named_ranges_of_every_sheet() {
|
||||
assert_eq!(list["names"], json!(["Areas", "Local", "Price", "Sheet1!Own", "type"]), "{list}");
|
||||
}
|
||||
|
||||
/// Thousands of names that alias other names are listed with one lookup per alias, not a scan of
|
||||
/// every name for each; aliases resolve through the chain like a single name does.
|
||||
#[test]
|
||||
fn go_to_lists_many_aliases_and_follows_them() {
|
||||
let mut s = go_to_workbook();
|
||||
let wb = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().wb);
|
||||
let alias = |name: String, formula: String| gridcraft_model::DefinedName { name, scope: None, formula, comment: String::new(), hidden: false };
|
||||
wb.names.push(alias("Chain0".into(), "Areas".into()));
|
||||
for i in 1..20_000 {
|
||||
wb.names.push(alias(format!("Chain{i}"), format!("Chain{},price", i - 1)));
|
||||
}
|
||||
let list = s.execute("edit.goTo", json!({"list": true})).unwrap();
|
||||
let names: Vec<&str> = list["names"].as_array().unwrap().iter().filter_map(|n| n.as_str()).collect();
|
||||
// Chain0..Chain6 are within the name depth Go To follows; longer chains are not ranges.
|
||||
for i in 0..7 {
|
||||
assert!(names.contains(&format!("Chain{i}").as_str()), "Chain{i} in {names:?}");
|
||||
}
|
||||
assert!(!names.contains(&"Chain7"), "{names:?}");
|
||||
let out = s.execute("edit.goTo", json!({"reference": "chain1"})).unwrap();
|
||||
assert_eq!(out["selection"], "A1,C3:C4,B2:B5", "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_to_a_name_scoped_to_another_sheet_goes_to_that_sheet() {
|
||||
let mut s = go_to_workbook();
|
||||
@@ -302,6 +324,103 @@ fn wildcards() {
|
||||
assert!(wildcard("", "*"));
|
||||
}
|
||||
|
||||
/// The backtracking matcher `wildcard` replaced, as the reference for its results.
|
||||
fn wildcard_backtracking(text: &str, pat: &str) -> bool {
|
||||
let t: Vec<char> = text.chars().collect();
|
||||
let mut p: Vec<(char, bool)> = Vec::new();
|
||||
let mut it = pat.chars();
|
||||
while let Some(c) = it.next() {
|
||||
if c == '~' {
|
||||
if let Some(n) = it.next() {
|
||||
p.push((n, true));
|
||||
}
|
||||
} else {
|
||||
p.push((c, false));
|
||||
}
|
||||
}
|
||||
let (mut ti, mut pi) = (0usize, 0usize);
|
||||
let (mut star, mut mark) = (None, 0usize);
|
||||
while ti < t.len() {
|
||||
if pi < p.len() && (p[pi] == ('?', false) || (p[pi].0 == t[ti] && p[pi] != ('*', false))) {
|
||||
ti += 1;
|
||||
pi += 1;
|
||||
} else if pi < p.len() && p[pi] == ('*', false) {
|
||||
star = Some(pi);
|
||||
mark = ti;
|
||||
pi += 1;
|
||||
} else if let Some(sp) = star {
|
||||
pi = sp + 1;
|
||||
mark += 1;
|
||||
ti = mark;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
while pi < p.len() && p[pi] == ('*', false) {
|
||||
pi += 1;
|
||||
}
|
||||
pi == p.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wildcard_agrees_with_backtracking() {
|
||||
// Every text up to 5 characters over a small alphabet against every pattern up to 4 items.
|
||||
let alphabet = ['a', 'b', '*', '?', '~'];
|
||||
let all = |n: usize, chars: &[char]| -> Vec<String> {
|
||||
let mut out = vec![String::new()];
|
||||
let mut level = vec![String::new()];
|
||||
for _ in 0..n {
|
||||
level = level.iter().flat_map(|s| chars.iter().map(move |c| format!("{s}{c}"))).collect();
|
||||
out.extend(level.iter().cloned());
|
||||
}
|
||||
out
|
||||
};
|
||||
let texts = all(5, &['a', 'b', '*']);
|
||||
for pat in all(4, &alphabet) {
|
||||
for text in &texts {
|
||||
assert_eq!(wildcard(text, &pat), wildcard_backtracking(text, &pat), "{text:?} ~ {pat:?}");
|
||||
}
|
||||
}
|
||||
// Patterns longer than one 64-bit word.
|
||||
let long = format!("*{}*b?", "a".repeat(70));
|
||||
for text in [format!("x{}yb1", "a".repeat(70)), format!("{}b", "a".repeat(71)), "a".repeat(200)] {
|
||||
assert_eq!(wildcard(&text, &long), wildcard_backtracking(&text, &long), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wildcard_with_many_distinct_literals_agrees_with_backtracking() {
|
||||
// 600 distinct characters in the pattern: more than the masks kept, so the rest are rebuilt.
|
||||
let chars: Vec<char> = (0x4E00u32..0x4E00 + 600).filter_map(char::from_u32).collect();
|
||||
let word: String = chars.iter().collect();
|
||||
let pat = format!("*{word}?{}", chars[3]);
|
||||
for text in [format!("x{word}yq{}", chars[3]), word.clone(), format!("{word}{word}"), chars.iter().rev().collect::<String>()] {
|
||||
assert_eq!(wildcard(&text, &pat), wildcard_backtracking(&text, &pat), "{} chars", text.chars().count());
|
||||
}
|
||||
assert!(wildcard(&format!("{word}zz{}", chars[3]).replace("zz", "z"), &pat));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wildcard_keeps_masks_of_frequent_literals_past_the_cache_limit() {
|
||||
// 300 distinct characters come before the repeated `a`: past the cached masks, `a` (5,000 positions)
|
||||
// still has to be read from a kept mask, or it is rebuilt for each of the 20,000 `a` in the text.
|
||||
let rare: String = (0x4E00u32..0x4E00 + 300).filter_map(char::from_u32).collect();
|
||||
let pat = format!("*{rare}{}b", "a".repeat(5_000));
|
||||
let text = "a".repeat(20_000);
|
||||
assert!(!wildcard(&text, &pat));
|
||||
assert!(wildcard(&format!("{rare}{}b", "a".repeat(5_000)), &pat));
|
||||
assert!(!wildcard(&format!("{rare}{}", "a".repeat(5_000)), &pat));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wildcard_on_long_repetitive_text_is_linear() {
|
||||
// The backtracking matcher compared about text × pattern characters here (10^8).
|
||||
let text = "a".repeat(20_000);
|
||||
let pat = format!("*{}b", "a".repeat(5_000));
|
||||
assert!(!wildcard(&text, &pat));
|
||||
assert!(wildcard(&format!("{text}b"), &pat));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tsv() {
|
||||
assert_eq!(parse_tsv("a\tb\n1\t2\n"), vec![vec!["a", "b"], vec!["1", "2"]]);
|
||||
@@ -424,6 +543,50 @@ fn drag_and_drop_on_another_sheet_stays_on_that_sheet() {
|
||||
assert_eq!(wb.sheet(two).unwrap().value(CellRef::new(0, 1)), Value::Empty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_refuses_text_that_would_grow_past_a_cell() {
|
||||
// 10,000 matches × a 1,000-character replacement would make a 10 MB cell (and a few such
|
||||
// cells gigabytes); the replacement is refused before that text is built.
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let long = "a".repeat(10_000);
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[long.clone()], ["abc"]]})).unwrap();
|
||||
for case in [false, true] {
|
||||
let err = s.execute("edit.replace", json!({"what": "a", "with": "x".repeat(1_000), "matchCase": case})).expect_err("too long");
|
||||
assert!(err.to_string().contains("too long"), "{err}");
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["value"], json!(long));
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "A2"})).unwrap()["value"], json!("abc"));
|
||||
}
|
||||
// A replacement that stays within a cell's limit still happens.
|
||||
s.execute("edit.replace", json!({"what": "b", "with": "x".repeat(100)})).unwrap();
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "A2"})).unwrap()["value"], json!(format!("a{}c", "x".repeat(100))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_refuses_a_result_past_the_character_limit_though_it_fits_the_byte_limit() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["z"]]})).unwrap();
|
||||
for whole in [false, true] {
|
||||
let err = s.execute("edit.replace", json!({"what": "z", "with": "x".repeat(32_768), "wholeCell": whole})).expect_err("32,768 characters");
|
||||
assert!(err.to_string().contains("too long"), "{err}");
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["value"], json!("z"));
|
||||
}
|
||||
s.execute("edit.replace", json!({"what": "z", "with": "x".repeat(32_767)})).unwrap();
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["value"], json!("x".repeat(32_767)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_replace_stops_at_its_limit() {
|
||||
use super::find::{replace_ci_within, replace_cs_within};
|
||||
assert_eq!(replace_cs_within("abab", "a", "xy", 6).as_deref(), Some("xybxyb"));
|
||||
assert_eq!(replace_cs_within("abab", "a", "xy", 5), None);
|
||||
assert_eq!(replace_ci_within("AbAb", "a", "xy", 6).as_deref(), Some("xybxyb"));
|
||||
assert_eq!(replace_ci_within("AbAb", "a", "xy", 5), None);
|
||||
assert_eq!(replace_cs_within("abc", "z", "", 3).as_deref(), Some("abc"));
|
||||
assert_eq!(replace_cs_within("abc", "z", "", 2), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_case_insensitive() {
|
||||
assert_eq!(replace_ci("Apple apple APPLE", "apple", "pear"), "pear pear pear");
|
||||
|
||||
@@ -42,6 +42,15 @@ pub(super) fn table_resize(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Most cells (rows × columns, blanks included) Get Data imports. The rows are built densely
|
||||
/// before they are written, so a few sparse records or one far-away CSV cell must not expand
|
||||
/// into a rectangle of billions of slots.
|
||||
const MAX_IMPORT_CELLS: u64 = 10_000_000;
|
||||
|
||||
fn too_many_cells() -> EngineError {
|
||||
EngineError::Other(format!("The data is too large to import ({MAX_IMPORT_CELLS} cells at most)."))
|
||||
}
|
||||
|
||||
pub(super) fn from_csv(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let bytes = if let Some(t) = str_param(p, "text") {
|
||||
t.as_bytes().to_vec()
|
||||
@@ -57,15 +66,27 @@ pub(super) fn from_csv(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if text.trim_start().starts_with('[') {
|
||||
let arr: Vec<Json> = serde_json::from_str(&text).map_err(|e| bad("data.getData", e.to_string()))?;
|
||||
let mut keys: Vec<String> = Vec::new();
|
||||
let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
||||
for o in &arr {
|
||||
if let Some(m) = o.as_object() {
|
||||
for k in m.keys() {
|
||||
if !keys.contains(k) {
|
||||
if !seen.contains(k.as_str()) {
|
||||
// Stop collecting at the column limit.
|
||||
if keys.len() >= gridcraft_core::MAX_COLS as usize {
|
||||
return Err(EngineError::Other("The data has more fields than a sheet has columns.".into()));
|
||||
}
|
||||
seen.insert(k.as_str());
|
||||
keys.push(k.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if arr.len() as u64 + 1 > u64::from(gridcraft_core::MAX_ROWS) {
|
||||
return Err(EngineError::Other("The data has more rows than a sheet has.".into()));
|
||||
}
|
||||
if (arr.len() as u64 + 1).saturating_mul(keys.len().max(1) as u64) > MAX_IMPORT_CELLS {
|
||||
return Err(too_many_cells());
|
||||
}
|
||||
let mut out = vec![keys.iter().map(|k| json!(k)).collect::<Vec<_>>()];
|
||||
for o in &arr {
|
||||
out.push(keys.iter().map(|k| o.get(k).cloned().unwrap_or(Json::Null)).collect());
|
||||
@@ -75,10 +96,17 @@ pub(super) fn from_csv(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let delim = str_param(p, "delimiter")
|
||||
.and_then(|d| d.chars().next())
|
||||
.unwrap_or(if text.lines().next().is_some_and(|l| l.contains('\t')) { '\t' } else { ',' });
|
||||
let opts = gridcraft_xlsx::CsvOptions { delimiter: delim as u8, ..Default::default() };
|
||||
let wb = gridcraft_xlsx::read_csv(&bytes, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let opts = gridcraft_xlsx::CsvOptions { delimiter: delim as u8, max_used_cells: Some(MAX_IMPORT_CELLS), ..Default::default() };
|
||||
// The cell limit is enforced while parsing, before an oversized file is stored.
|
||||
let wb = gridcraft_xlsx::read_csv(&bytes, &opts).map_err(|e| match e {
|
||||
gridcraft_xlsx::IoError::TooLarge(_) => too_many_cells(),
|
||||
e => EngineError::Other(e.to_string()),
|
||||
})?;
|
||||
let sh = wb.sheet(0).ok_or(EngineError::NoDocument)?;
|
||||
let Some(u) = sh.used_range() else { return Err(EngineError::Other("The file is empty.".into())) };
|
||||
if u.count() > MAX_IMPORT_CELLS {
|
||||
return Err(too_many_cells());
|
||||
}
|
||||
(u.start.row..=u.end.row)
|
||||
.map(|r| {
|
||||
(u.start.col..=u.end.col)
|
||||
@@ -129,3 +157,35 @@ pub(super) fn queries(s: &mut Session, _: &Json) -> Result<Json> {
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn s() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sparse_imports_are_refused_before_their_rows_are_built() {
|
||||
let mut s = s();
|
||||
let sheets = s.doc().unwrap().wb.sheets.len();
|
||||
// 5,000 records with one field each: 5,001 × 5,000 slots once every record gets every field.
|
||||
let records: Vec<_> = (0..5_000).map(|i| json!({ format!("k{i}"): i })).collect();
|
||||
let text = serde_json::to_string(&records).unwrap();
|
||||
let err = s.execute("data.getData", json!({"text": text})).expect_err("too many cells");
|
||||
assert!(err.to_string().contains("too large"), "{err}");
|
||||
// A CSV whose last line reaches 16,001 columns, 1,000 lines down.
|
||||
let text = format!("1{}{}x", "\n".repeat(999), ",".repeat(16_000));
|
||||
let err = s.execute("data.fromTextCsv", json!({"text": text})).expect_err("too many cells");
|
||||
assert!(err.to_string().contains("too large"), "{err}");
|
||||
assert_eq!(s.doc().unwrap().wb.sheets.len(), sheets);
|
||||
// Small imports still work.
|
||||
let out = s.execute("data.getData", json!({"text": r#"[{"a": 1}, {"b": 2}]"#})).unwrap();
|
||||
assert_eq!((out["rows"].as_u64(), out["cols"].as_u64()), (Some(3), Some(2)), "{out}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,13 +36,28 @@ fn max_periods(history: usize) -> u32 {
|
||||
u32::try_from(MAX_WORK / h).unwrap_or(u32::MAX).clamp(1, MAX_PERIODS)
|
||||
}
|
||||
|
||||
/// Most forecast rows when the model is fit to the first `fit` of `history` rows, which span
|
||||
/// `slots` timeline slots once their gaps are filled. With a Forecast Start inside the history,
|
||||
/// every row after it is forecast too (a hindcast row holds the same ETS formulas, each refitting
|
||||
/// the model over the `slots` slots), so those rows (counted from the raw rows) count against
|
||||
/// [`MAX_WORK`] before any forecast row does. `None` when the hindcast alone exceeds it.
|
||||
fn max_periods_from(history: usize, fit: usize, slots: usize) -> Option<u32> {
|
||||
if fit >= history {
|
||||
return Some(max_periods(history));
|
||||
}
|
||||
let f = u64::try_from(slots.max(fit)).unwrap_or(u64::MAX).max(1);
|
||||
let hindcast = u64::try_from(history - fit).unwrap_or(u64::MAX);
|
||||
let rest = (MAX_WORK / f).checked_sub(hindcast).filter(|r| *r >= 1)?;
|
||||
Some(u32::try_from(rest).unwrap_or(u32::MAX).min(MAX_PERIODS))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
"data.forecastSheet",
|
||||
"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), 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.",
|
||||
"{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, or with a forecastStart inside the history (hindcast rows are forecast too) (the greater of the fitted rows and fitted slots × (periods + rows from forecastStart on, that row included)) ≤ 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
|
||||
)]
|
||||
@@ -257,13 +272,23 @@ fn forecast_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
};
|
||||
let cap = max_periods(rows.len());
|
||||
// The slots the model is fit over: the fit rows' span of the timeline, gaps included.
|
||||
let slots = match (fit < rows.len(), times.get(fit.wrapping_sub(1)), times.first()) {
|
||||
(true, Some(last_fit), Some(first)) => match (timeline.steps_after_last(*last_fit), timeline.steps_after_last(*first)) {
|
||||
(Some(b), Some(a)) => ((b - a).round() + 1.0).clamp(0.0, 1e9) as usize,
|
||||
_ => fit,
|
||||
},
|
||||
_ => fit,
|
||||
};
|
||||
let cap = max_periods_from(rows.len(), fit, slots).ok_or_else(|| {
|
||||
EngineError::Other("The forecast start is too early for this much history: choose a later forecast start or less history.".into())
|
||||
})?;
|
||||
let periods = match end {
|
||||
Some(end) => steps_to(&timeline, end, cap)?,
|
||||
None => match number(p, "periods")? {
|
||||
Some(n) if n >= 1.0 && n.ceil() <= f64::from(cap) => n.ceil() as u32,
|
||||
Some(_) => return Err(bad(CMD, format!("`periods` is from 1 to {cap} for {} rows of history", rows.len()))),
|
||||
None => default_periods(rows.len()),
|
||||
None => default_periods(rows.len()).min(cap),
|
||||
},
|
||||
};
|
||||
let future: Vec<f64> =
|
||||
@@ -532,6 +557,51 @@ mod tests {
|
||||
assert_eq!(s.doc().unwrap().wb.sheets.len(), sheets - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hindcast_rows_count_against_the_work_limit() {
|
||||
use super::{MAX_WORK, max_periods, max_periods_from};
|
||||
assert_eq!(max_periods_from(1_300, 1_300, 1_300), Some(max_periods(1_300)));
|
||||
// 2,000 rows fit to the first 1,899: 101 hindcast rows, then 162 forecast rows.
|
||||
assert_eq!(max_periods_from(2_000, 1_899, 1_899), Some(162));
|
||||
// Fit to the first 1,000: the 1,000 hindcast rows alone are twice the limit.
|
||||
assert_eq!(max_periods_from(2_000, 1_000, 1_000), None);
|
||||
// 800 rows fit to the first 400, which span 571 slots (gaps filled): each formula refits
|
||||
// 571 slots, so 475 forecast rows after the 400 hindcast rows, not 850.
|
||||
assert_eq!(max_periods_from(800, 400, 571), Some(475));
|
||||
assert!((475 + 400) * 571 <= MAX_WORK as usize);
|
||||
for (h, f) in [(2_000usize, 1_899usize), (10_000, 9_990), (100, 2), (60, 50)] {
|
||||
if let Some(p) = max_periods_from(h, f, f) {
|
||||
assert!((u64::from(p) + (h - f) as u64) * f as u64 <= MAX_WORK, "{h} {f}");
|
||||
}
|
||||
}
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let mut rows = vec![json!(["t", "y"])];
|
||||
rows.extend((1..=2_000).map(|t| json!([t, (t % 7) as f64 + t as f64 * 0.5])));
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
|
||||
let sheets = s.doc().unwrap().wb.sheets.len();
|
||||
let err = s.execute("data.forecastSheet", json!({"range": "A1:B2001", "forecastStart": 1_001})).expect_err("hindcast too long");
|
||||
assert!(err.to_string().contains("forecast start is too early"), "{err}");
|
||||
let err = s.execute("data.forecastSheet", json!({"range": "A1:B2001", "forecastStart": 1_900, "periods": 163})).expect_err("past the cap");
|
||||
assert!(err.to_string().contains("from 1 to 162"), "{err}");
|
||||
assert_eq!(s.doc().unwrap().wb.sheets.len(), sheets);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hindcast_cap_follows_the_slots_the_fit_spans_not_its_rows() {
|
||||
// 800 rows, 20% of the slots missing: the first 400 rows span 500 slots, so each ETS
|
||||
// formula refits 500 slots and the cap is 1,000 - 400 = 600 (it was 850 by rows).
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
let times: Vec<u32> = (1u32..).filter(|t| !matches!(t % 10, 3 | 6)).take(800).collect();
|
||||
let mut rows = vec![json!(["t", "y"])];
|
||||
rows.extend(times.iter().map(|t| json!([t, (t % 7) as f64 + f64::from(*t) * 0.5])));
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
|
||||
let start = times[400];
|
||||
let err = s.execute("data.forecastSheet", json!({"range": "A1:B801", "forecastStart": start, "periods": 601})).expect_err("past the cap");
|
||||
assert!(err.to_string().contains("from 1 to 600"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_and_periods_are_capped() {
|
||||
use super::{MAX_HISTORY, MAX_PERIODS, MAX_WORK, default_periods, max_periods};
|
||||
|
||||
@@ -85,7 +85,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Format Cells…",
|
||||
["Home", "Cells", "Format"],
|
||||
Some("Cmd+1"),
|
||||
"{range?, style: {font?, fill?, borders?, align?, numFmt?, protection?}} (partial Style JSON merged in)",
|
||||
"{range?, style: {font?, fill?, borders?, align?: {reading_order?: Context|LeftToRight|RightToLeft|null, ...}, numFmt?, protection?}} (partial Style JSON merged in; null clears reading_order)",
|
||||
has_doc,
|
||||
format_cells
|
||||
),
|
||||
@@ -870,6 +870,67 @@ mod tests {
|
||||
assert_eq!(s.execute("cell.get", json!({"cell": "A1"})).unwrap()["style"]["num_fmt"], "0.00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_vowel_and_direction_marks_add_no_width() {
|
||||
let plain = approx_text_width("محمد يدرس في المدرسة", 11.0, false);
|
||||
assert_eq!(approx_text_width("مُحَمَّد يَدْرُسُ فِي الْمَدْرَسَةِ", 11.0, false), plain);
|
||||
assert_eq!(approx_text_width("\u{200F}محمد يدرس في المدرسة\u{200C}", 11.0, false), plain);
|
||||
assert_eq!(approx_text_width("ب\u{0897}\u{0898}\u{089F}", 11.0, false), approx_text_width("ب", 11.0, false));
|
||||
assert!(approx_text_width("\u{08E2}", 11.0, false) > 0.0, "the disputed end of ayah is a visible sign");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_text_fits_a_column_about_as_wide_as_latin_text_of_the_same_length() {
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["مُحَمَّد يَدْرُسُ فِي الْمَدْرَسَةِ", "Hello there in school"]]})).unwrap();
|
||||
s.execute("home.autofitColumnWidth", json!({"cols": "A:B"})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let width = |c: u32| doc.wb.active().and_then(|sh| sh.cols.get(&c)).and_then(|e| e.size).unwrap();
|
||||
let ratio = width(0) / width(1);
|
||||
assert!((0.75..=1.15).contains(&ratio), "Arabic {} vs Latin {}", width(0), width(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autofit_of_many_separate_columns_fits_only_the_selected_ones() {
|
||||
let mut s = crate::Session::new();
|
||||
s.new_workbook();
|
||||
let long = "a much longer text than the others";
|
||||
// Columns A, C, E, G, I, K are selected (six runs), the ones between hold long text too.
|
||||
let row: Vec<_> = (0..11).map(|c| if c % 2 == 0 { json!("x") } else { json!(long) }).collect();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [row]})).unwrap();
|
||||
s.execute("home.autofitColumnWidth", json!({"cols": "A:A,C:C,E:E,G:G,I:I,K:K"})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let size = |c: u32| doc.wb.active().and_then(|sh| sh.cols.get(&c)).and_then(|e| e.size);
|
||||
assert!([0, 2, 4, 6, 8, 10].iter().all(|&c| size(c).is_some()));
|
||||
assert!([1, 3, 5, 7, 9].iter().all(|&c| size(c).is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_word_ligatures_are_wider_than_letters() {
|
||||
let em = |t: &str| approx_text_width(t, 72.0, false) / 96.0;
|
||||
assert!(em("\u{FDFD}") > 8.0);
|
||||
assert!(em("\u{FDFA}") > 1.0);
|
||||
let honorifics = ['\u{FD40}', '\u{FD4B}', '\u{FD4C}', '\u{FD4F}', '\u{FDC8}', '\u{FDCF}', '\u{FDFE}', '\u{FDFF}'];
|
||||
for honorific in honorifics.into_iter().chain(['\u{FBC3}', '\u{FBD2}', '\u{FD90}', '\u{FD91}']) {
|
||||
assert!(em(&honorific.to_string()) > 1.0, "U+{:04X}", u32::from(honorific));
|
||||
}
|
||||
assert!(em("ب") < 0.6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arabic_digits_are_as_wide_as_ascii_digits_and_punctuation_as_a_letter() {
|
||||
let width = |t: &str| approx_text_width(t, 11.0, false);
|
||||
assert_eq!(width("١٢٣"), width("123"));
|
||||
assert_eq!(width("۴۵۶"), width("456"));
|
||||
assert_eq!(width("،؟"), width("بب"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cjk_text_keeps_a_full_em_per_character() {
|
||||
assert_eq!(approx_text_width("中文", 12.0, false), 2.0 * 12.0 * 96.0 / 72.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn width_units() {
|
||||
assert_eq!(chars_to_points(8.43), 64.0);
|
||||
|
||||
@@ -136,12 +136,55 @@ pub fn approx_text_width(text: &str, size: f32, bold: bool) -> f32 {
|
||||
c if c.is_ascii_uppercase() => 0.65,
|
||||
c if c.is_ascii_digit() => 0.56,
|
||||
c if c.is_ascii() => 0.52,
|
||||
_ => 1.0,
|
||||
c if is_zero_width(c) => 0.0,
|
||||
c => arabic_em(c).unwrap_or(1.0),
|
||||
};
|
||||
}
|
||||
w * size * if bold { 1.07 } else { 1.0 } * 96.0 / 72.0
|
||||
}
|
||||
|
||||
/// Arabic vowel and Quranic marks, which sit on the previous letter, and invisible format
|
||||
/// characters such as direction marks and zero-width joiners.
|
||||
fn is_zero_width(c: char) -> bool {
|
||||
matches!(
|
||||
c,
|
||||
'\u{0610}'..='\u{061A}'
|
||||
| '\u{061C}'
|
||||
| '\u{064B}'..='\u{065F}'
|
||||
| '\u{0670}'
|
||||
| '\u{06D6}'..='\u{06DC}'
|
||||
| '\u{06DF}'..='\u{06E4}'
|
||||
| '\u{06E7}'..='\u{06E8}'
|
||||
| '\u{06EA}'..='\u{06ED}'
|
||||
| '\u{0897}'..='\u{089F}'
|
||||
| '\u{08CA}'..='\u{08E1}'
|
||||
| '\u{08E3}'..='\u{08FF}'
|
||||
| '\u{200B}'..='\u{200F}'
|
||||
| '\u{202A}'..='\u{202E}'
|
||||
| '\u{2060}'..='\u{2069}'
|
||||
| '\u{FE20}'..='\u{FE2F}'
|
||||
| '\u{FEFF}'
|
||||
)
|
||||
}
|
||||
|
||||
/// Width in ems of an Arabic-script character, measured from shaped Noto Sans Arabic, Noto Naskh
|
||||
/// Arabic and DejaVu Sans: joined letters average about half an em, word ligatures such as ﷺ and the
|
||||
/// rial sign about 1.2 em, the honorific ligatures (U+FD40–FD4B) about 1.6 em, and the bismillah
|
||||
/// ligature about 11 em. The later honorific ligatures (U+FBC3–FBD2, U+FD4C–FD4F, U+FD90–FD91,
|
||||
/// U+FDC8–FDCF) are not measured and get the 1.6 em of their siblings rather than the letter width.
|
||||
/// Arabic-Indic digits are as wide as ASCII digits (0.53–0.57 em in Noto Sans Arabic, Arial, Tahoma
|
||||
/// and Times New Roman); Arabic punctuation (0.2–0.5 em in the same fonts) keeps the letter width.
|
||||
fn arabic_em(c: char) -> Option<f32> {
|
||||
match c {
|
||||
'\u{FDFD}' => Some(11.0),
|
||||
'\u{FBC3}'..='\u{FBD2}' | '\u{FD40}'..='\u{FD4F}' | '\u{FD90}'..='\u{FD91}' | '\u{FDC8}'..='\u{FDCF}' => Some(1.6),
|
||||
'\u{FDF0}'..='\u{FDFC}' | '\u{FDFE}'..='\u{FDFF}' => Some(1.2),
|
||||
'\u{0660}'..='\u{0669}' | '\u{06F0}'..='\u{06F9}' => Some(0.56),
|
||||
'\u{0600}'..='\u{06FF}' | '\u{0750}'..='\u{077F}' | '\u{0870}'..='\u{08FF}' | '\u{FB50}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFC}' => Some(0.48),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Distinct texts AutoFit measures exactly; past this many, `Session::text_width` is asked for a
|
||||
/// quick measure so a huge column doesn't lay out every value.
|
||||
const EXACT_TEXT_WIDTHS: usize = 2_000;
|
||||
@@ -151,16 +194,35 @@ pub(super) fn autofit_cols(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let measure = s.text_width.clone();
|
||||
edit(s, |cx| {
|
||||
let Some(sh) = cx.wb.sheet(si) else { return Ok(Json::Null) };
|
||||
let mut widths = Vec::new();
|
||||
let mut measured: std::collections::HashMap<StyleId, std::collections::HashMap<String, f32>> = Default::default();
|
||||
let mut distinct = 0usize;
|
||||
let used = sh.used_range();
|
||||
for (a, b) in lines {
|
||||
for c in a..=b.min(a + 16383) {
|
||||
let Some(u) = used else { continue };
|
||||
let col = RangeRef::new(CellRef::new(u.start.row, c), CellRef::new(u.end.row, c));
|
||||
let mut max: f32 = 0.0;
|
||||
for (cell, _) in sh.cells.iter_range(col) {
|
||||
// Selected columns, then one pass over the populated cells (the store is row-oriented, so a
|
||||
// scan per column would revisit every row for each selected column, empty or not).
|
||||
let cols: std::collections::BTreeSet<u32> = lines.iter().flat_map(|&(a, b)| a..=b.min(a + 16383)).collect();
|
||||
let mut maxes: std::collections::BTreeMap<u32, f32> = Default::default();
|
||||
if let Some(u) = sh.used_range() {
|
||||
// One pass per run of adjacent selected columns, so columns between distant selections
|
||||
// (A and XFD) are never visited.
|
||||
let mut runs: Vec<(u32, u32)> = Vec::new();
|
||||
for &c in &cols {
|
||||
match runs.last_mut() {
|
||||
Some((_, end)) if *end + 1 == c => *end = c,
|
||||
_ => runs.push((c, c)),
|
||||
}
|
||||
}
|
||||
// Every run is a pass over all the stored rows: many runs (alternating columns) are one
|
||||
// pass over their whole span instead, the unselected columns skipped.
|
||||
if runs.len() > 4
|
||||
&& let (Some(&(first, _)), Some(&(_, last))) = (runs.first(), runs.last())
|
||||
{
|
||||
runs = vec![(first, last)];
|
||||
}
|
||||
for (first, last) in runs {
|
||||
let span = RangeRef::new(CellRef::new(u.start.row, first), CellRef::new(u.end.row, last));
|
||||
for (cell, _) in sh.cells.iter_range(span) {
|
||||
if !cols.contains(&cell.col) {
|
||||
continue;
|
||||
}
|
||||
if sh.merge_at(cell).is_some_and(|m| !m.is_single()) {
|
||||
continue;
|
||||
}
|
||||
@@ -183,14 +245,14 @@ pub(super) fn autofit_cols(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
None => approx_text_width(line, st.font.size, st.font.bold),
|
||||
};
|
||||
max = max.max(w + 6.0 + st.align.indent as f32 * 9.0 + sh.filter_button_space(cell));
|
||||
let w = w + 6.0 + st.align.indent as f32 * 9.0 + sh.filter_button_space(cell);
|
||||
let max = maxes.entry(cell.col).or_insert(0.0);
|
||||
*max = max.max(w);
|
||||
}
|
||||
}
|
||||
if max > 0.0 {
|
||||
widths.push((c, max.clamp(8.0, 1785.0)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let widths: Vec<(u32, f32)> = maxes.into_iter().filter(|&(_, m)| m > 0.0).map(|(c, m)| (c, m.clamp(8.0, 1785.0))).collect();
|
||||
let sh = cx.sheet_mut(si)?;
|
||||
for (c, w) in widths {
|
||||
let e = sh.cols.entry(c).or_default();
|
||||
|
||||
@@ -179,10 +179,7 @@ fn guessed_range(sh: &gridcraft_model::sheet::Sheet, at: CellRef) -> Option<Rang
|
||||
let mut range = None;
|
||||
if at.row > 0 && (numeric(CellRef::new(at.row - 1, at.col)) || at.col == 0 || !numeric(CellRef::new(at.row, at.col - 1))) {
|
||||
let mut top = at.row - 1;
|
||||
while top > 0
|
||||
&& (numeric(CellRef::new(top - 1, at.col))
|
||||
|| sh.value(CellRef::new(top - 1, at.col)).is_empty() && top > 1 && numeric(CellRef::new(top - 2, at.col)))
|
||||
{
|
||||
while top > 0 && numeric(CellRef::new(top - 1, at.col)) {
|
||||
top -= 1;
|
||||
}
|
||||
if numeric(CellRef::new(at.row - 1, at.col)) {
|
||||
|
||||
@@ -209,6 +209,41 @@ fn show_values_as() {
|
||||
assert_eq!(text(&s, "B3"), "Biggest sale");
|
||||
}
|
||||
|
||||
/// Running Total over nested rows adds up each child's value across the top-level items, in the
|
||||
/// order they appear: the same value as summing the plain values of the earlier items' rows.
|
||||
#[test]
|
||||
fn running_total_over_nested_rows_adds_up_across_the_top_level_items() {
|
||||
let mut s = sales();
|
||||
s.execute("insert.pivotTable", json!({"rows": ["Region", "Product"], "values": ["Sales"]})).unwrap();
|
||||
let r = pt(&s).last_range.unwrap();
|
||||
let rows: Vec<u32> = (r.start.row + 1..r.end.row).collect();
|
||||
let read = |s: &Session| -> Vec<(String, f64)> {
|
||||
rows.iter().map(|row| (text(s, &CellRef::new(*row, 0).a1()), val(s, &CellRef::new(*row, 1).a1()).as_f64().unwrap_or(0.0))).collect()
|
||||
};
|
||||
let before = read(&s);
|
||||
s.execute("pivot.valueSettings", json!({"index": 0, "showAs": "runningTotal"})).unwrap();
|
||||
let after = read(&s);
|
||||
// Region rows carry the report's top-level items (not indented); the others are products.
|
||||
let indent = |s: &Session, row: u32| {
|
||||
let d = s.doc().unwrap();
|
||||
let sh = d.wb.active().unwrap();
|
||||
d.wb.styles.get(sh.style_id(CellRef::new(row, 0))).align.indent
|
||||
};
|
||||
let (mut regions, mut products) = (0.0, std::collections::HashMap::<String, f64>::new());
|
||||
for (i, row) in rows.iter().enumerate() {
|
||||
let name = &before[i].0;
|
||||
let total = if indent(&s, *row) == 0 {
|
||||
regions += before[i].1;
|
||||
regions
|
||||
} else {
|
||||
let t = products.entry(name.clone()).or_insert(0.0);
|
||||
*t += before[i].1;
|
||||
*t
|
||||
};
|
||||
assert!((after[i].1 - total).abs() < 1e-9, "row {name}: {} vs {total}", after[i].1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_vs_tabular_shapes() {
|
||||
let mut s = sales();
|
||||
|
||||
@@ -32,8 +32,69 @@ use header_footer::draw_header_footer;
|
||||
pub use header_footer::{
|
||||
HF_FIELDS, HfContext, HfSection, codes_to_fields, expand_header_footer, fields_to_codes, hf_date_time, join_header_footer, split_header_footer,
|
||||
};
|
||||
/// Decoded pictures (bytes, width, height) keyed by (sheet, picture); `None` = undecodable.
|
||||
type PicCache = std::collections::HashMap<(usize, usize), Option<(Vec<u8>, u32, u32)>>;
|
||||
type DecodedPicture = (Vec<u8>, u32, u32);
|
||||
|
||||
/// What one PDF render shares across the pages an object spans.
|
||||
#[derive(Default)]
|
||||
struct PicCache {
|
||||
/// Decoded pictures (bytes, width, height) keyed by (sheet, picture); `None` = undecodable.
|
||||
decoded: std::collections::HashMap<(usize, usize), Option<DecodedPicture>>,
|
||||
/// Laid-out charts (with their [`chart_cost`]) keyed by (sheet, chart), rendered once however
|
||||
/// many pages they touch. Only charts that can still be drawn are kept.
|
||||
charts: std::collections::HashMap<(usize, usize), (Vec<gridcraft_chart::Prim>, usize)>,
|
||||
/// Charts too big for what was left of the budget: never drawn, so not rendered again.
|
||||
rejected_charts: std::collections::HashSet<(usize, usize)>,
|
||||
/// Bytes of picture pixels embedded so far, and chart cost units emitted so far. Each page an
|
||||
/// object touches gets its own copy in the PDF, so a workbook-controlled anchor over thousands
|
||||
/// of pages would multiply them without bound.
|
||||
image_bytes: usize,
|
||||
chart_prims: usize,
|
||||
}
|
||||
|
||||
impl PicCache {
|
||||
/// The chart's primitives for one more page, or `None` once they no longer fit
|
||||
/// [`MAX_CHART_PRIMS`]. `render` runs at most once per chart; a chart that doesn't fit is
|
||||
/// dropped at once and remembered, so its vectors are never retained.
|
||||
fn chart(&mut self, key: (usize, usize), render: impl FnOnce() -> Vec<gridcraft_chart::Prim>) -> Option<&[gridcraft_chart::Prim]> {
|
||||
if self.rejected_charts.contains(&key) {
|
||||
return None;
|
||||
}
|
||||
let (prims, cost) = match self.charts.remove(&key) {
|
||||
Some(cached) => cached,
|
||||
None => {
|
||||
let prims = render();
|
||||
let cost = chart_cost(&prims);
|
||||
(prims, cost)
|
||||
}
|
||||
};
|
||||
if self.chart_prims.saturating_add(cost) > MAX_CHART_PRIMS {
|
||||
self.rejected_charts.insert(key);
|
||||
return None;
|
||||
}
|
||||
self.chart_prims += cost;
|
||||
Some(&self.charts.entry(key).or_insert((prims, cost)).0)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a chart's drawing costs in the PDF: one unit per primitive plus one per path point (a
|
||||
/// smoothed line holds thousands of points, each written out) and per point of a wedge's outline.
|
||||
fn chart_cost(prims: &[gridcraft_chart::Prim]) -> usize {
|
||||
use gridcraft_chart::Prim;
|
||||
prims
|
||||
.iter()
|
||||
.map(|p| match p {
|
||||
Prim::Line { pts, .. } | Prim::Polygon { pts, .. } => pts.len().saturating_add(1),
|
||||
// At most 400 steps along each of the outer and inner arcs (see `draw_prims`).
|
||||
Prim::Wedge { .. } => 803,
|
||||
_ => 1,
|
||||
})
|
||||
.fold(0, usize::saturating_add)
|
||||
}
|
||||
|
||||
/// Most picture pixel bytes (RGBA, before PDF packing) one render embeds across all pages.
|
||||
const MAX_EMBEDDED_IMAGE_BYTES: usize = 512 * 1024 * 1024;
|
||||
/// Most chart cost units (primitives and path points, see [`chart_cost`]) one render draws across all pages.
|
||||
const MAX_CHART_PRIMS: usize = 4_000_000;
|
||||
|
||||
#[cfg(test)]
|
||||
use layout::bands;
|
||||
@@ -102,7 +163,7 @@ pub fn render_pdf(wb: &Workbook, layouts: &[SheetLayout], file: &str, path: &str
|
||||
let mut printed_blocks = Vec::new();
|
||||
// Decoded pictures are reused across the pages their anchor covers: a floating picture
|
||||
// spanning 40 pages was decoded (and pushed into the PDF) 40 times (#2389).
|
||||
let mut pics = PicCache::new();
|
||||
let mut pics = PicCache::default();
|
||||
for lay in layouts {
|
||||
let Some(sh) = wb.sheet(lay.sheet) else { continue };
|
||||
let mut cf = if sh.cond_formats.is_empty() { None } else { Some(crate::cf::CfCache::new()) };
|
||||
|
||||
@@ -228,6 +228,82 @@ fn draw_border(pg: &mut Page, b: &BorderLine, wb: &Workbook, start: Point, end:
|
||||
pg.stroke_line_dashed(Point::new(x0, y0), Point::new(x1, y1), lw, color, &border_dash(b.style, k));
|
||||
}
|
||||
|
||||
/// Merged areas active at one row of a downward sweep, kept disjoint: by first column, their last
|
||||
/// column and last row. Adding, dropping and looking up a merge costs log(active merges).
|
||||
#[derive(Default)]
|
||||
struct MergeSweep {
|
||||
cols: std::collections::BTreeMap<u32, (u32, u32)>,
|
||||
/// (last row, first column) of the active merges, soonest ending first.
|
||||
ends: std::collections::BinaryHeap<std::cmp::Reverse<(u32, u32)>>,
|
||||
}
|
||||
|
||||
impl MergeSweep {
|
||||
/// Moves down to `row` (rows never decrease), dropping the merges that end above it.
|
||||
fn advance(&mut self, row: u32) {
|
||||
while let Some(std::cmp::Reverse((end, col))) = self.ends.peek().copied() {
|
||||
if end >= row {
|
||||
break;
|
||||
}
|
||||
self.ends.pop();
|
||||
self.cols.remove(&col);
|
||||
}
|
||||
}
|
||||
|
||||
/// Does an active merge meet columns `c0..=c1`? The intervals are disjoint, so only the last
|
||||
/// one starting at or before `c1` can.
|
||||
fn meets(&self, c0: u32, c1: u32) -> bool {
|
||||
self.cols.range(..=c1).next_back().is_some_and(|(_, (end, _))| *end >= c0)
|
||||
}
|
||||
|
||||
/// Adds merge `m`, at row `row`, unless it overlaps an active one.
|
||||
fn add(&mut self, m: RangeRef, row: u32) -> bool {
|
||||
self.advance(row);
|
||||
if self.meets(m.start.col, m.end.col) {
|
||||
return false;
|
||||
}
|
||||
self.cols.insert(m.start.col, (m.end.col, m.end.row));
|
||||
self.ends.push(std::cmp::Reverse((m.end.row, m.start.col)));
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// The merged areas that meet `block`, ordered by the row where they enter it, then by column,
|
||||
/// leaving out any that overlaps one kept before it (only a damaged file has overlapping merges;
|
||||
/// Excel drops them when it repairs one). One sweep down the rows instead of a scan of every
|
||||
/// merge per cell.
|
||||
fn block_merges(sh: &Sheet, block: RangeRef) -> Vec<RangeRef> {
|
||||
let top = |m: &RangeRef| m.start.row.max(block.start.row);
|
||||
let mut merges: Vec<RangeRef> = sh.merges.iter().filter(|m| m.intersects(&block)).copied().collect();
|
||||
merges.sort_by_key(|m| (top(m), m.start.col));
|
||||
let mut sweep = MergeSweep::default();
|
||||
merges.retain(|m| sweep.add(*m, top(m)));
|
||||
merges
|
||||
}
|
||||
|
||||
/// Answers "is this cell inside a merge?" for cells asked in row-major order, from the merges of
|
||||
/// [`block_merges`].
|
||||
struct Covered<'a> {
|
||||
merges: &'a [RangeRef],
|
||||
top: u32,
|
||||
next: usize,
|
||||
sweep: MergeSweep,
|
||||
}
|
||||
|
||||
impl<'a> Covered<'a> {
|
||||
fn new(merges: &'a [RangeRef], block: RangeRef) -> Self {
|
||||
Self { merges, top: block.start.row, next: 0, sweep: MergeSweep::default() }
|
||||
}
|
||||
|
||||
fn covers(&mut self, c: CellRef) -> bool {
|
||||
while let Some(m) = self.merges.get(self.next).filter(|m| m.start.row.max(self.top) <= c.row) {
|
||||
self.sweep.add(*m, m.start.row.max(self.top));
|
||||
self.next += 1;
|
||||
}
|
||||
self.sweep.advance(c.row);
|
||||
self.sweep.meets(c.col, c.col)
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the cells of a block: fills, gridlines, data bars, borders and text.
|
||||
pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Option<crate::cf::CfCache>) {
|
||||
let (bx, by) = (b.xs.first().copied().unwrap_or(0.0), b.ys.first().copied().unwrap_or(0.0));
|
||||
@@ -237,17 +313,18 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
|
||||
}
|
||||
let block = b.range();
|
||||
let sh = b.sh;
|
||||
let merges: Vec<RangeRef> = sh.merges.iter().filter(|m| m.intersects(&block)).copied().collect();
|
||||
let covered = |c: CellRef| merges.iter().any(|m| m.contains(c));
|
||||
// Cells worth visiting.
|
||||
let merges = block_merges(sh, block);
|
||||
let by_start: std::collections::HashMap<CellRef, RangeRef> = merges.iter().map(|m| (m.start, *m)).collect();
|
||||
// Cells worth visiting, in row-major order (which `Covered` needs).
|
||||
let mut covered = Covered::new(&merges, block);
|
||||
let cells: Vec<CellRef> = if block.count() <= DENSE_BLOCK {
|
||||
block.iter().filter(|c| !covered(*c)).collect()
|
||||
block.iter().filter(|c| !covered.covers(*c)).collect()
|
||||
} else {
|
||||
let mut set: Vec<CellRef> = sh.cells.iter_range(block).map(|(c, _)| c).collect();
|
||||
set.extend(sh.spill.range(block.start..=block.end).map(|(c, _)| *c).filter(|c| block.contains(*c)));
|
||||
set.sort_unstable();
|
||||
set.dedup();
|
||||
set.retain(|c| !covered(*c));
|
||||
set.retain(|c| !covered.covers(*c));
|
||||
set
|
||||
};
|
||||
pg.save();
|
||||
@@ -277,14 +354,14 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
|
||||
.filter(|(c, l)| {
|
||||
l.fill.is_some()
|
||||
|| l.cf.is_some()
|
||||
|| merges.iter().any(|m| m.start == *c)
|
||||
|| by_start.contains_key(c)
|
||||
|| !sh.value(*c).is_empty()
|
||||
|| l.style.borders != Borders::default()
|
||||
|| l.style.num_fmt.as_str() == "checkbox"
|
||||
})
|
||||
.collect();
|
||||
let rect_of = |c: CellRef| -> (f32, f32, f32, f32, bool) {
|
||||
match merges.iter().find(|m| m.start == c) {
|
||||
match by_start.get(&c) {
|
||||
Some(m) => {
|
||||
let (x, y, w, h) = b.rect(*m);
|
||||
(x, y, w, h, true)
|
||||
@@ -322,7 +399,7 @@ pub(super) fn draw_block(pg: &mut Page, b: &Block, gridlines: bool, cf: &mut Opt
|
||||
continue;
|
||||
}
|
||||
let bd = &l.style.borders;
|
||||
let (right, bottom) = match merges.iter().find(|m| m.start == *c).filter(|_| merged) {
|
||||
let (right, bottom) = match by_start.get(c).filter(|_| merged) {
|
||||
Some(m) => (
|
||||
b.wb.styles.get(sh.style_id(CellRef::new(m.start.row, m.end.col))).borders.right,
|
||||
b.wb.styles.get(sh.style_id(CellRef::new(m.end.row, m.start.col))).borders.bottom,
|
||||
@@ -407,11 +484,12 @@ pub(super) struct PrintedBlock {
|
||||
pub(super) fn draw_block_links(pg: &mut Page, b: &Block, page: usize, pending: &mut Vec<PendingPdfLink>) {
|
||||
let range = b.range();
|
||||
let (Some(&bx), Some(&by), Some(&right), Some(&bottom)) = (b.xs.first(), b.ys.first(), b.xs.last(), b.ys.last()) else { return };
|
||||
let by_start: std::collections::HashMap<CellRef, RangeRef> = block_merges(b.sh, range).into_iter().map(|m| (m.start, m)).collect();
|
||||
for (cell, link) in b.sh.hyperlinks.range(range.start..=range.end) {
|
||||
if !range.contains(*cell) {
|
||||
continue;
|
||||
}
|
||||
let cell_range = b.sh.merges.iter().find(|m| m.start == *cell).copied().unwrap_or(RangeRef::cell(*cell));
|
||||
let cell_range = by_start.get(cell).copied().unwrap_or(RangeRef::cell(*cell));
|
||||
let (x, y, w, h) = b.rect(cell_range);
|
||||
let (x0, y0, x1, y1) = (x.max(bx), y.max(by), (x + w).min(right), (y + h).min(bottom));
|
||||
if x1 <= x0 || y1 <= y0 {
|
||||
@@ -503,3 +581,44 @@ pub(super) fn make_block<'a>(
|
||||
}
|
||||
Block { wb, si, sh, rows, cols, xs, ys, k, scale: lay.scale }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod merge_tests {
|
||||
use super::{CellRef, Covered, RangeRef, Sheet, block_merges};
|
||||
|
||||
fn r(a: &str) -> RangeRef {
|
||||
RangeRef::parse(a).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_merges_drop_overlaps_and_cover_like_a_scan() {
|
||||
let mut sh = Sheet::new("S");
|
||||
sh.merges = vec![r("B2:C3"), r("E1:F8"), r("B2:B5"), r("A10:D10"), r("H3:H4"), r("Z1:Z2"), r("C3:D4"), r("A1:A1048576")];
|
||||
let block = r("B1:H12");
|
||||
let merges = block_merges(&sh, block);
|
||||
// B2:B5 and C3:D4 overlap B2:C3, which comes first; Z1:Z2 is outside the block.
|
||||
assert_eq!(merges, vec![r("E1:F8"), r("B2:C3"), r("H3:H4"), r("A10:D10")]);
|
||||
let mut covered = Covered::new(&merges, block);
|
||||
for c in block.iter() {
|
||||
assert_eq!(covered.covers(c), merges.iter().any(|m| m.contains(c)), "{}", c.a1());
|
||||
}
|
||||
// Cells asked sparsely, still in order.
|
||||
let mut covered = Covered::new(&merges, block);
|
||||
for (a, want) in [("C1", false), ("F5", true), ("G5", false), ("D10", true), ("H12", false)] {
|
||||
assert_eq!(covered.covers(CellRef::parse(a).unwrap()), want, "{a}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_stacked_merges_are_swept_once() {
|
||||
// 50,000 merges over the same cells: one is kept, and the block is checked against it
|
||||
// without comparing every cell with every merge.
|
||||
let mut sh = Sheet::new("S");
|
||||
sh.merges = (0..50_000u32).map(|i| RangeRef::new(CellRef::new(0, 0), CellRef::new(199, 1 + i % 50))).collect();
|
||||
let block = r("A1:AZ200");
|
||||
let merges = block_merges(&sh, block);
|
||||
assert_eq!(merges, vec![r("A1:B200")]);
|
||||
let mut covered = Covered::new(&merges, block);
|
||||
assert_eq!(block.iter().filter(|c| covered.covers(*c)).count(), 400);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,6 +224,12 @@ pub fn layout_sheet(wb: &Workbook, si: usize, opts: &PrintOptions, range_overrid
|
||||
Some(r) => trim_full(sh, r),
|
||||
None => print_range(sh).unwrap_or(RangeRef::cell(CellRef::new(0, 0))),
|
||||
};
|
||||
// A print area from a file or JSON can name coordinates past the grid; pagination walks every
|
||||
// line of the area (zero-height rows never fill a band), so it is held to the grid first.
|
||||
let area = RangeRef::new(
|
||||
CellRef::new(area.start.row.min(MAX_ROWS - 1), area.start.col.min(MAX_COLS - 1)),
|
||||
CellRef::new(area.end.row.min(MAX_ROWS - 1), area.end.col.min(MAX_COLS - 1)),
|
||||
);
|
||||
let title_rows = title_span(st.title_rows, MAX_ROWS);
|
||||
let title_cols = title_span(st.title_cols, MAX_COLS);
|
||||
let digits = format!("{}", area.end.row + 1).len() as f32;
|
||||
@@ -379,3 +385,19 @@ pub(super) fn segments(titles: Option<(u32, u32)>, band: (u32, u32)) -> Vec<(u32
|
||||
pub(super) fn seg_size(sh: &Sheet, seg: (u32, u32), rows: bool) -> f32 {
|
||||
if rows { (seg.0..=seg.1).map(|r| sh.row_height(r)).sum() } else { (seg.0..=seg.1).map(|c| sh.col_width(c)).sum() }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_print_area_past_the_grid_is_held_to_the_grid() {
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
sh.default_row_height = 0.0;
|
||||
sh.print.print_area = Some(RangeRef { start: CellRef::new(0, 0), end: CellRef::new(u32::MAX, u32::MAX) });
|
||||
let lay = layout_sheet(&wb, 0, &PrintOptions::default(), None).unwrap();
|
||||
assert_eq!((lay.area.end.row, lay.area.end.col), (MAX_ROWS - 1, MAX_COLS - 1));
|
||||
assert!(lay.pages.len() <= MAX_PAGES);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,9 +88,14 @@ pub(super) fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet,
|
||||
let (x, y, w, h) = place(r);
|
||||
// About 150 dpi is plenty for print.
|
||||
let (mw, mh) = (((w / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32, ((h / 72.0) * 150.0).ceil().clamp(1.0, 4000.0) as u32);
|
||||
let decoded = pics.entry((si, imi)).or_insert_with(|| decode_image(&im.data, mw, mh));
|
||||
let decoded = pics.decoded.entry((si, imi)).or_insert_with(|| decode_image(&im.data, mw, mh));
|
||||
if let Some((px, pw, ph)) = decoded.as_ref() {
|
||||
pg.image_rgba(Rect::new(x, y, w, h), px, [*pw, *ph]);
|
||||
// Every page the picture touches embeds its own copy: past the budget it is left out.
|
||||
let bytes = px.len();
|
||||
if pics.image_bytes.saturating_add(bytes) <= super::MAX_EMBEDDED_IMAGE_BYTES {
|
||||
pics.image_bytes += bytes;
|
||||
pg.image_rgba(Rect::new(x, y, w, h), px, [*pw, *ph]);
|
||||
}
|
||||
}
|
||||
}
|
||||
for shp in &sh.shapes {
|
||||
@@ -120,15 +125,20 @@ pub(super) fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet,
|
||||
}
|
||||
draw_shape(pg, wb, shp, b, (x, y, w, h));
|
||||
}
|
||||
for ch in &sh.charts {
|
||||
for (chi, ch) in sh.charts.iter().enumerate() {
|
||||
let r = anchor_rect(sh, &ch.anchor);
|
||||
if !visible(r) {
|
||||
continue;
|
||||
}
|
||||
let (x, y, _, _) = place(r);
|
||||
let data = gridcraft_chart::resolve(wb, si, ch);
|
||||
let prims = gridcraft_chart::render(ch, &data, r.2 as f32, r.3 as f32, &PdfMeasure);
|
||||
draw_prims(pg, &prims, Xf { ox: x, oy: y, k: b.k });
|
||||
// As for pictures, each page gets its own copy of the drawing commands.
|
||||
let prims = pics.chart((si, chi), || {
|
||||
let data = gridcraft_chart::resolve(wb, si, ch);
|
||||
gridcraft_chart::render(ch, &data, r.2 as f32, r.3 as f32, &PdfMeasure)
|
||||
});
|
||||
if let Some(prims) = prims {
|
||||
draw_prims(pg, prims, Xf { ox: x, oy: y, k: b.k });
|
||||
}
|
||||
}
|
||||
pg.restore();
|
||||
}
|
||||
|
||||
@@ -938,3 +938,27 @@ fn equations_print_with_the_symbol_font() {
|
||||
// A glyph no standard font has is a [?] box.
|
||||
assert!(contains(&pdf, "(?) Tj"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chart_budget_counts_path_points_and_drops_charts_that_do_not_fit() {
|
||||
use gridcraft_chart::Prim;
|
||||
let line = |n: usize| Prim::Line { pts: vec![[0.0, 0.0]; n], color: [0, 0, 0, 255], width: 1.0, dash: false };
|
||||
// A smoothed line is its points, not one unit.
|
||||
assert_eq!(chart_cost(&[line(4000), Prim::Circle { cx: 0.0, cy: 0.0, r: 1.0, fill: None, stroke: None }]), 4002);
|
||||
// Room for exactly two copies of a 1,000-point chart.
|
||||
let mut pics = PicCache { chart_prims: MAX_CHART_PRIMS - 2002, ..Default::default() };
|
||||
let mut renders = 0;
|
||||
for expect in [true, true, false, false] {
|
||||
let got = pics.chart((0, 0), || {
|
||||
renders += 1;
|
||||
vec![line(1000)]
|
||||
});
|
||||
assert_eq!(got.is_some(), expect);
|
||||
}
|
||||
assert_eq!(renders, 1, "a rejected chart is not rendered again");
|
||||
assert!(pics.charts.is_empty(), "and its primitives are not kept");
|
||||
// A chart too big for what is left is dropped without being cached at all.
|
||||
let mut pics = PicCache { chart_prims: MAX_CHART_PRIMS - 10, ..Default::default() };
|
||||
assert!(pics.chart((0, 1), || vec![line(1000)]).is_none());
|
||||
assert!(pics.charts.is_empty() && pics.rejected_charts.contains(&(0, 1)));
|
||||
}
|
||||
|
||||
@@ -163,6 +163,8 @@ fn protect_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let what = d.wb.sheet(sheet).map(|sh| sh.name.clone()).unwrap_or_default();
|
||||
let prot = SheetProtection { password: str_param(p, "password").filter(|x| !x.is_empty()).map(|pw| new_password_hash(d, &what, pw)), ..prot };
|
||||
edit(s, |cx| {
|
||||
// FORMULATEXT reads a sheet's protection: its results are recalculated.
|
||||
cx.recalc_all = true;
|
||||
cx.sheet_mut(sheet)?.protection = Some(prot.clone());
|
||||
Ok(Json::Null)
|
||||
})
|
||||
@@ -182,7 +184,10 @@ fn unprotect_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
edit(s, |cx| {
|
||||
cx.sheet_mut(sheet)?.protection = None;
|
||||
// Only removing protection changes what FORMULATEXT reads; a no-op unprotect skips the full recalculation.
|
||||
if cx.sheet_mut(sheet)?.protection.take().is_some() {
|
||||
cx.recalc_all = true;
|
||||
}
|
||||
Ok(Json::Null)
|
||||
})
|
||||
}
|
||||
@@ -404,6 +409,21 @@ mod tests {
|
||||
assert_eq!(at(&s), (0, "A1".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protecting_and_unprotecting_leave_table_headers_alone() {
|
||||
let mut s = Session::new();
|
||||
s.new_workbook();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["a", "b"], [1, 2]]})).unwrap();
|
||||
s.execute("insert.table", json!({"range": "A1:B2", "header": true})).unwrap();
|
||||
// An imported header can hold a number: only a structural edit would turn it back into text.
|
||||
let d = s.doc_mut().unwrap();
|
||||
std::sync::Arc::make_mut(&mut d.wb).sheet_mut(0).unwrap().set_value(gridcraft_core::CellRef::new(0, 0), gridcraft_core::Value::Number(7.0));
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
s.execute("review.unprotectSheet", json!({})).unwrap();
|
||||
let v = s.doc().unwrap().wb.sheet(0).unwrap().value(gridcraft_core::CellRef::new(0, 0));
|
||||
assert_eq!(v, gridcraft_core::Value::Number(7.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protection_that_forbids_selecting_locked_cells_is_enforced_and_kept() {
|
||||
let mut s = book();
|
||||
|
||||
@@ -55,11 +55,12 @@ pub(super) fn delete_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
cx.wb.sheets.get(si).map(|s| s.cells.iter().filter_map(|(c, x)| x.formula.clone().map(|f| (c, f))).collect()).unwrap_or_default();
|
||||
let Some(sh) = cx.wb.sheet_mut(si) else { continue };
|
||||
for (c, f) in keys {
|
||||
if let Some(e) = f.expr() {
|
||||
let ne = gridcraft_formula::adjust::delete_sheet(e, &name, &order);
|
||||
if let Some(cell) = sh.cells.get_mut(c) {
|
||||
cell.formula = Some(Arc::new(gridcraft_model::Formula::from_expr(ne)));
|
||||
}
|
||||
// Only formulas whose tree changes are rewritten; the others keep their text.
|
||||
let changed = f.with_expr(|e| Some(gridcraft_formula::adjust::delete_sheet(e.clone(), &name, &order)).filter(|ne| ne != e));
|
||||
if let Some(Some(ne)) = changed
|
||||
&& let Some(cell) = sh.cells.get_mut(c)
|
||||
{
|
||||
cell.formula = Some(Arc::new(gridcraft_model::Formula::from_expr(ne)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,14 +110,12 @@ pub(super) fn rename_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
cx.wb.sheets.get(si).map(|s| s.cells.iter().filter_map(|(c, x)| x.formula.clone().map(|f| (c, f))).collect()).unwrap_or_default();
|
||||
let Some(sh) = cx.wb.sheet_mut(si) else { continue };
|
||||
for (c, f) in keys {
|
||||
if let Some(e) = f.expr() {
|
||||
let ne = gridcraft_formula::adjust::rename_sheet(e, &old, &name);
|
||||
let text = gridcraft_formula::print(&ne);
|
||||
if text != f.text
|
||||
&& let Some(cell) = sh.cells.get_mut(c)
|
||||
{
|
||||
cell.formula = Some(Arc::new(gridcraft_model::Formula::from_expr(ne)));
|
||||
}
|
||||
// Compares trees, so unchanged formulas are not printed; they keep their text.
|
||||
let changed = f.with_expr(|e| Some(gridcraft_formula::adjust::rename_sheet(e.clone(), &old, &name)).filter(|ne| ne != e));
|
||||
if let Some(Some(ne)) = changed
|
||||
&& let Some(cell) = sh.cells.get_mut(c)
|
||||
{
|
||||
cell.formula = Some(Arc::new(gridcraft_model::Formula::from_expr(ne)));
|
||||
}
|
||||
}
|
||||
for ch in sh.charts.iter_mut() {
|
||||
|
||||
@@ -52,14 +52,19 @@ pub(super) fn abs_ref(c: CellRef) -> String {
|
||||
format!("${}${}", gridcraft_core::col_to_letters(c.col), u64::from(c.row) + 1)
|
||||
}
|
||||
|
||||
/// How many cells to the left and above a cell its name is searched for.
|
||||
const NAME_REACH: u32 = 1_000;
|
||||
|
||||
/// A cell's name in the reports: the nearest text to its left and the nearest text above it.
|
||||
pub(super) fn cell_name(sh: &Sheet, c: CellRef) -> String {
|
||||
let text = |c: CellRef| match sh.value(c) {
|
||||
Value::Text(t) if !t.trim().is_empty() => Some(t.to_string()),
|
||||
_ => None,
|
||||
};
|
||||
let left = (0..c.col).rev().find_map(|col| text(CellRef::new(c.row, col)));
|
||||
let top = (0..c.row).rev().find_map(|row| text(CellRef::new(row, c.col)));
|
||||
// Labels are next to their cells: looking further than this on a sparse sheet only costs time
|
||||
// (a thousand cells near the last row would each probe a million empty coordinates).
|
||||
let left = (c.col.saturating_sub(NAME_REACH)..c.col).rev().find_map(|col| text(CellRef::new(c.row, col)));
|
||||
let top = (c.row.saturating_sub(NAME_REACH)..c.row).rev().find_map(|row| text(CellRef::new(row, c.col)));
|
||||
[left, top].into_iter().flatten().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
@@ -438,3 +443,17 @@ pub(super) fn report(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_name_is_searched_only_a_bounded_distance() {
|
||||
let mut sh = Sheet::new("S".to_string());
|
||||
sh.set_cell(CellRef::new(0, 0), gridcraft_model::Cell::value(Value::text("Far")));
|
||||
sh.set_cell(CellRef::new(5, 4), gridcraft_model::Cell::value(Value::text("Near")));
|
||||
assert_eq!(cell_name(&sh, CellRef::new(5, 5)), "Near");
|
||||
assert_eq!(cell_name(&sh, CellRef::new(0, NAME_REACH + 1)), "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,9 @@ pub(crate) fn criterion_matches(v: &Value, text: &str, crit: &Value) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Most (list row, criteria row) pairs one Advanced Filter tests.
|
||||
const MAX_FILTER_TESTS: u64 = 50_000_000;
|
||||
|
||||
pub(super) fn advanced_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
const CMD: &str = "data.advancedFilter";
|
||||
let d = s.doc()?;
|
||||
@@ -133,6 +136,10 @@ pub(super) fn advanced_filter(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
rows.push(conds);
|
||||
}
|
||||
}
|
||||
// Each list row is tried against every criteria row, so bound their product too.
|
||||
if u64::from(list.height()) * rows.len() as u64 > MAX_FILTER_TESTS {
|
||||
return Err(bad(CMD, "the list and criteria ranges together are too large"));
|
||||
}
|
||||
let matches_row = |row: u32| -> bool {
|
||||
if rows.is_empty() {
|
||||
return true;
|
||||
|
||||
@@ -61,6 +61,24 @@ enum Key {
|
||||
|
||||
const MAX_CONSOLIDATE_CELLS: u64 = 5_000_000;
|
||||
|
||||
/// The cells a consolidated table writes: every row key meets every column key, plus a label for
|
||||
/// each key on the sides that have labels.
|
||||
fn output_cells(rows: usize, cols: usize, top: bool, left: bool) -> u64 {
|
||||
let (r, c) = (rows as u64, cols as u64);
|
||||
r.saturating_mul(c).saturating_add(if top { c } else { 0 }).saturating_add(if left { r } else { 0 })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn the_output_limit_counts_label_cells() {
|
||||
// A million row keys and four column keys are 4 million cells, 5 million with the row labels.
|
||||
assert_eq!(output_cells(1_000_000, 4, false, false), 4_000_000);
|
||||
assert_eq!(output_cells(1_000_000, 4, false, true), 5_000_000);
|
||||
assert!(output_cells(1_000_000, 5, false, true) > MAX_CONSOLIDATE_CELLS);
|
||||
assert_eq!(output_cells(3, 2, true, true), 6 + 2 + 3);
|
||||
assert_eq!(output_cells(usize::MAX, usize::MAX, true, true), u64::MAX);
|
||||
}
|
||||
|
||||
pub(super) fn consolidate(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
const CMD: &str = "data.consolidate";
|
||||
let func = str_param(p, "function").unwrap_or("sum").to_string();
|
||||
@@ -89,20 +107,20 @@ pub(super) fn consolidate(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
None => (d.wb.active_sheet(), d.selection.active),
|
||||
};
|
||||
// Keys in first-seen order, each with its index: a lookup is one probe, not a scan.
|
||||
let mut row_keys: Vec<Key> = Vec::new();
|
||||
let mut col_keys: Vec<Key> = Vec::new();
|
||||
let mut row_index: HashMap<Key, usize> = HashMap::new();
|
||||
let mut col_index: HashMap<Key, usize> = HashMap::new();
|
||||
let mut row_label: HashMap<Key, String> = HashMap::new();
|
||||
let mut col_label: HashMap<Key, String> = HashMap::new();
|
||||
let mut aggs: HashMap<(usize, usize), Agg> = HashMap::new();
|
||||
let mut total: u64 = 0;
|
||||
let index_of = |keys: &mut Vec<Key>, k: Key| -> usize {
|
||||
match keys.iter().position(|x| *x == k) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
keys.push(k);
|
||||
keys.len() - 1
|
||||
}
|
||||
}
|
||||
let index_of = |keys: &mut Vec<Key>, index: &mut HashMap<Key, usize>, k: Key| -> usize {
|
||||
*index.entry(k).or_insert_with_key(|k| {
|
||||
keys.push(k.clone());
|
||||
keys.len() - 1
|
||||
})
|
||||
};
|
||||
for t in &srcs {
|
||||
let (si, r) = parse_range(&d.wb, t, CMD)?;
|
||||
@@ -131,7 +149,7 @@ pub(super) fn consolidate(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
} else {
|
||||
Key::Pos(col - c0)
|
||||
};
|
||||
cols.push((col, index_of(&mut col_keys, k)));
|
||||
cols.push((col, index_of(&mut col_keys, &mut col_index, k)));
|
||||
}
|
||||
for row in r0..=r.end.row {
|
||||
let k = if left {
|
||||
@@ -145,7 +163,7 @@ pub(super) fn consolidate(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
} else {
|
||||
Key::Pos(row - r0)
|
||||
};
|
||||
let ri = index_of(&mut row_keys, k);
|
||||
let ri = index_of(&mut row_keys, &mut row_index, k);
|
||||
for (col, ci) in &cols {
|
||||
aggs.entry((ri, *ci)).or_default().add(&sh.value(CellRef::new(row, *col)));
|
||||
}
|
||||
@@ -157,6 +175,9 @@ pub(super) fn consolidate(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
{
|
||||
return Err(EngineError::Other("The consolidated data doesn't fit below and right of the destination.".into()));
|
||||
}
|
||||
if output_cells(row_keys.len(), col_keys.len(), top, left) > MAX_CONSOLIDATE_CELLS {
|
||||
return Err(bad(CMD, "the consolidated table would be too large"));
|
||||
}
|
||||
let mut cells: Vec<(CellRef, Value)> = Vec::new();
|
||||
if top {
|
||||
for (ci, k) in col_keys.iter().enumerate() {
|
||||
|
||||
@@ -189,6 +189,9 @@ pub(super) fn scenario_delete(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Ok(json!({"deleted": removed.name}))
|
||||
}
|
||||
|
||||
/// Most changing-cell-by-scenario cells one Scenario Summary writes.
|
||||
const MAX_SUMMARY_CELLS: usize = 1_000_000;
|
||||
|
||||
pub(super) fn scenario_summary(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
const CMD: &str = "data.scenarioSummary";
|
||||
let d = s.doc()?;
|
||||
@@ -211,17 +214,24 @@ pub(super) fn scenario_summary(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
// Changing cells of every scenario, first-seen order; per scenario its values.
|
||||
let mut changing: Vec<CellRef> = Vec::new();
|
||||
let mut per: Vec<HashMap<CellRef, Value>> = Vec::new();
|
||||
let mut seen: std::collections::HashSet<CellRef> = Default::default();
|
||||
for sc in &scenarios {
|
||||
let (_, cells) = cell_list(&d.wb, &sc.changing, CMD, MAX_SCENARIO_CELLS)?;
|
||||
let mut m = HashMap::new();
|
||||
for (c, v) in cells.iter().zip(sc.values.iter()) {
|
||||
if !changing.contains(c) {
|
||||
if seen.insert(*c) {
|
||||
changing.push(*c);
|
||||
}
|
||||
m.insert(*c, json_value(v, &d.wb));
|
||||
}
|
||||
per.push(m);
|
||||
}
|
||||
// The summary has a cell for every changing cell in every scenario, filled from the current
|
||||
// value where a scenario doesn't set it, so small disjoint scenarios multiply.
|
||||
// Each result cell is a row of the summary too, with a cell per scenario.
|
||||
if changing.len().saturating_add(results.len()).saturating_mul(per.len()) > MAX_SUMMARY_CELLS {
|
||||
return Err(bad(CMD, "the scenarios together change too many cells for a summary"));
|
||||
}
|
||||
let current: Vec<Value> = changing.iter().map(|c| sh.value(*c)).collect();
|
||||
let current_results: Vec<Value> = results.iter().map(|c| sh.value(*c)).collect();
|
||||
let mut trial = Trial::new(d);
|
||||
|
||||
@@ -297,3 +297,47 @@ fn consolidate_by_position_and_label() {
|
||||
assert!(s.execute("data.consolidate", json!({"sources": []})).is_err());
|
||||
assert!(s.execute("data.consolidate", json!({"sources": ["A1"], "function": "median"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_filter_refuses_a_list_times_criteria_product_over_the_work_limit() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [["Name"], ["A"]]})).unwrap();
|
||||
s.execute("range.setValues", json!({"range": "H1", "values": [["Name"], ["A"]]})).unwrap();
|
||||
// A sparse million-row list against 9,999 blank criteria rows would be ~10 billion tests.
|
||||
assert!(s.execute("data.advancedFilter", json!({"range": "A2:A1048576", "criteria": "H1:H10000"})).is_err());
|
||||
}
|
||||
|
||||
/// A hundred thousand positional rows take one key lookup each; a tall source and a wide one
|
||||
/// whose keys would meet in a table of 160 million cells are refused before it is built.
|
||||
#[test]
|
||||
fn consolidate_keys_are_indexed_and_the_output_is_bounded() {
|
||||
let mut s = s();
|
||||
s.execute("range.setValues", json!({"range": "A1", "values": [[1], [2]]})).unwrap();
|
||||
let r = s.execute("data.consolidate", json!({"sources": ["A1:A100000"], "destination": "C1"})).unwrap();
|
||||
assert_eq!(r["rows"], 100_000);
|
||||
assert_eq!(n(&s, "Sheet1!C2"), 2.0);
|
||||
let r = s.execute("data.consolidate", json!({"sources": ["A1:A10000", "A1:XFC1"], "destination": "A1"}));
|
||||
assert!(r.unwrap_err().to_string().contains("too large"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_summary_refuses_a_matrix_over_its_cell_limit() {
|
||||
let mut s = s();
|
||||
// 200 scenarios of 32 disjoint cells: 6,400 changing cells x 200 scenarios = 1.28 million cells.
|
||||
for i in 0..200 {
|
||||
let (a, b) = (i * 32 + 1, i * 32 + 32);
|
||||
s.execute("data.scenarioAdd", json!({"name": format!("S{i}"), "changing": format!("A{a}:A{b}")})).unwrap();
|
||||
}
|
||||
assert!(s.execute("data.scenarioSummary", json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_summary_counts_result_rows_in_its_cell_limit() {
|
||||
let mut s = s();
|
||||
// 900 scenarios of one distinct cell each are 810,000 cells; 255 result rows take it past the limit.
|
||||
for i in 0..900 {
|
||||
s.execute("data.scenarioAdd", json!({"name": format!("S{i}"), "changing": format!("A{}", i + 1)})).unwrap();
|
||||
}
|
||||
let err = s.execute("data.scenarioSummary", json!({"resultCells": "C1:C255"})).unwrap_err().to_string();
|
||||
assert!(err.contains("too many cells"), "{err}");
|
||||
}
|
||||
|
||||
@@ -8,8 +8,13 @@ use gridcraft_model::{Sheet, Workbook};
|
||||
use gridcraft_numfmt::{Formatted, NumberFormat, format_value};
|
||||
|
||||
/// Parsed number formats are cached by code.
|
||||
fn parsed(code: &str) -> NumberFormat {
|
||||
pub(crate) fn parsed(code: &str) -> NumberFormat {
|
||||
static CACHE: std::sync::LazyLock<Mutex<HashMap<String, NumberFormat>>> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
// A code of more bytes than 4 × the character limit can't be within it: it shows as General
|
||||
// without being hashed or scanned again for every cell that shares it.
|
||||
if code.len() > 4 * gridcraft_numfmt::MAX_FORMAT_CODE_CHARS {
|
||||
return NumberFormat::parse("General");
|
||||
}
|
||||
let lock = || CACHE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(f) = lock().get(code) {
|
||||
return f.clone();
|
||||
@@ -24,7 +29,12 @@ fn parsed(code: &str) -> NumberFormat {
|
||||
f
|
||||
}
|
||||
|
||||
/// The parsed format of `code`; one longer than the character limit is a General format that keeps
|
||||
/// the code it was made from. The engine itself formats through `parsed`, which doesn't copy it.
|
||||
pub fn number_format(code: &str) -> NumberFormat {
|
||||
if code.len() > 4 * gridcraft_numfmt::MAX_FORMAT_CODE_CHARS {
|
||||
return NumberFormat::parse(code);
|
||||
}
|
||||
parsed(code)
|
||||
}
|
||||
|
||||
@@ -228,3 +238,31 @@ pub fn stats(sheet: &Sheet, ranges: &[RangeRef]) -> Stats {
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A format code far past Excel's 255 characters shows its values as General, for every cell
|
||||
/// that shares it, without being hashed into the cache each time.
|
||||
#[test]
|
||||
fn an_overlong_code_formats_as_general() {
|
||||
let wb = Workbook::new();
|
||||
let code = format!("0.00{}", "\"x\"".repeat(400_000));
|
||||
for _ in 0..1_000 {
|
||||
assert_eq!(format(&Value::Number(1.5), &code, &wb).text, "1.5");
|
||||
}
|
||||
assert_eq!(format(&Value::Number(1.5), "0.00", &wb).text, "1.50");
|
||||
}
|
||||
|
||||
/// The public `number_format` keeps the code of an overlong format; it is General, but not the
|
||||
/// same format as a real `General`.
|
||||
#[test]
|
||||
fn number_format_keeps_an_overlong_code() {
|
||||
let code = format!("0.00{}", "\"x\"".repeat(2_000));
|
||||
let f = number_format(&code);
|
||||
assert!(f.is_general());
|
||||
assert_eq!(f.code(), code);
|
||||
assert_ne!(f, number_format("General"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,18 @@ pub fn holds_source_in_corner(src: RangeRef, target: RangeRef) -> bool {
|
||||
|
||||
/// Fills `target` (which contains `src` at one edge) from the pattern in `src`.
|
||||
pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: FillMode) -> Result<()> {
|
||||
fill_except(cx, sheet, src, target, mode, &std::collections::BTreeSet::new())
|
||||
}
|
||||
|
||||
/// [`fill`], leaving the rows in `skip_rows` untouched (Fill Down over a filtered range).
|
||||
pub fn fill_except(
|
||||
cx: &mut Ctx,
|
||||
sheet: usize,
|
||||
src: RangeRef,
|
||||
target: RangeRef,
|
||||
mode: FillMode,
|
||||
skip_rows: &std::collections::BTreeSet<u32>,
|
||||
) -> Result<()> {
|
||||
let custom = cx.wb.custom_lists.clone();
|
||||
let Some(sh) = cx.wb.sheet(sheet) else { return Ok(()) };
|
||||
// Direction: down/up if target extends rows, else right/left.
|
||||
@@ -119,7 +131,7 @@ pub fn fill(cx: &mut Ctx, sheet: usize, src: RangeRef, target: RangeRef, mode: F
|
||||
(c, sh.cell(c).cloned())
|
||||
})
|
||||
.collect();
|
||||
let targets: Vec<CellRef> = if vertical {
|
||||
let mut targets: Vec<CellRef> = if vertical {
|
||||
let rows: Vec<u32> =
|
||||
if forward { (src.end.row + 1..=target.end.row).collect() } else { (target.start.row..src.start.row).rev().collect() };
|
||||
rows.into_iter().map(|r| CellRef::new(r, lane)).collect()
|
||||
@@ -128,6 +140,7 @@ 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()
|
||||
};
|
||||
targets.retain(|t| !skip_rows.contains(&t.row));
|
||||
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 mode.extends_series() || mode == FillMode::Alternate {
|
||||
let first_fmt = src_cells.first().map(fmt).unwrap_or_default();
|
||||
|
||||
+172
-39
@@ -3,12 +3,19 @@
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use gridcraft_core::{CellRef, RangeRef};
|
||||
use gridcraft_model::{Sheet, Workbook};
|
||||
use gridcraft_model::{LineIndex, Sheet, Workbook};
|
||||
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
mod sylk;
|
||||
|
||||
/// The function library's answers to what the XLSX and XLS readers and the XLSX writer need to know about functions
|
||||
/// (which names are built in, which parameters take a single value).
|
||||
pub const XLSX_FUNCTIONS: gridcraft_xlsx::FunctionInfo = gridcraft_xlsx::FunctionInfo {
|
||||
is_builtin: |name| gridcraft_functions::lookup(name).is_some(),
|
||||
is_scalar_arg: |name, i| gridcraft_functions::lookup(name).is_none_or(|s| s.is_scalar_arg(i)),
|
||||
};
|
||||
|
||||
/// File formats GridCraft reads or writes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum FileKind {
|
||||
@@ -93,14 +100,18 @@ pub fn open_bytes_with(name: &str, bytes: &[u8], password: Option<&str>, csv_as_
|
||||
/// 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) {
|
||||
// The whole pass shares one budget of merge comparisons: later sheets keep their heights once it is spent.
|
||||
let mut left = crate::cmd::MERGE_FIT_WORK;
|
||||
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();
|
||||
// One sweep over the sheet's merges for all of its rows, in ascending order.
|
||||
let mut sweep = crate::cmd::MergeSweep::new(left);
|
||||
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);
|
||||
crate::cmd::fit_row_height(wb, si, row, &mut sweep);
|
||||
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)
|
||||
@@ -109,6 +120,7 @@ fn fit_imported_rows(wb: &mut Workbook) {
|
||||
sh.rows.entry(row).or_default().size = Some(before);
|
||||
}
|
||||
}
|
||||
left = sweep.left();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,7 +276,8 @@ fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook,
|
||||
let kind = FileKind::from_path(name);
|
||||
let sniffed = gridcraft_xlsx::sniff(bytes);
|
||||
if sniffed == gridcraft_xlsx::Format::Xls {
|
||||
let (wb, report) = gridcraft_xlsx::read_xls(bytes).map_err(|e| EngineError::Other(format!("We can't open '{name}': {e}")))?;
|
||||
let (wb, report) =
|
||||
gridcraft_xlsx::read_xls_with(bytes, XLSX_FUNCTIONS).map_err(|e| EngineError::Other(format!("We can't open '{name}': {e}")))?;
|
||||
return Ok((wb, report.warnings));
|
||||
}
|
||||
if sniffed == gridcraft_xlsx::Format::LegacyBinary {
|
||||
@@ -289,12 +302,13 @@ fn read_plain(name: &str, bytes: &[u8], csv_as_text: bool) -> Result<(Workbook,
|
||||
// Text named `.xlsx` is read as text.
|
||||
if sniffed == gridcraft_xlsx::Format::Xlsx || (kind == Some(FileKind::Xlsx) && sniffed != gridcraft_xlsx::Format::Csv) {
|
||||
// Google Sheets' export wrapper is read as the formula inside when GridCraft calculates it (#2871).
|
||||
let (wb, report) = gridcraft_xlsx::read_xlsx_with(bytes, &gridcraft_calc::is_known_function).map_err(|e| {
|
||||
EngineError::Other(match (&e, gridcraft_xlsx::oversized_part(bytes)) {
|
||||
(gridcraft_xlsx::IoError::TooLarge(_), Some((part, size))) => part_too_large_message(name, &part, size),
|
||||
_ => format!("We can't open '{name}': {e}"),
|
||||
})
|
||||
})?;
|
||||
let (wb, report) = gridcraft_xlsx::read_xlsx_with(bytes, (XLSX_FUNCTIONS, &gridcraft_calc::is_known_function as &dyn Fn(&str) -> bool))
|
||||
.map_err(|e| {
|
||||
EngineError::Other(match (&e, gridcraft_xlsx::oversized_part(bytes)) {
|
||||
(gridcraft_xlsx::IoError::TooLarge(_), Some((part, size))) => part_too_large_message(name, &part, size),
|
||||
_ => format!("We can't open '{name}': {e}"),
|
||||
})
|
||||
})?;
|
||||
return Ok((wb, report.warnings));
|
||||
}
|
||||
if let Some(read) = read_html_table(name, kind, bytes, csv_as_text) {
|
||||
@@ -502,7 +516,7 @@ pub fn save_bytes_as(wb: &Workbook, path: &str, kind: FileKind) -> Result<Vec<u8
|
||||
None => named,
|
||||
};
|
||||
let book = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or_default();
|
||||
gridcraft_xlsx::write_xlsx_named(named.as_ref().unwrap_or(wb), gridcraft_xlsx::XlsxKind::from_path(path), book)
|
||||
gridcraft_xlsx::write_xlsx_named_with(named.as_ref().unwrap_or(wb), gridcraft_xlsx::XlsxKind::from_path(path), book, XLSX_FUNCTIONS)
|
||||
.map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
FileKind::Xls => {
|
||||
@@ -567,7 +581,7 @@ pub fn csv_values_bytes(wb: &Workbook) -> Result<Vec<u8>> {
|
||||
let Some(sh) = wb.sheet(wb.active_sheet()) else { return Ok(Vec::new()) };
|
||||
gridcraft_xlsx::write_csv(sh, wb.csv_delimiter.unwrap_or(b','), |c| match sh.value(c) {
|
||||
gridcraft_core::Value::Number(n) if n.is_finite() => {
|
||||
let shape = crate::display::number_format(wb.styles.get(sh.style_id(c)).num_fmt.as_str()).date_shape(n);
|
||||
let shape = crate::display::parsed(wb.styles.get(sh.style_id(c)).num_fmt.as_str()).date_shape(n);
|
||||
match shape.zip(gridcraft_core::date::datetime_from_serial(wb.date_system, n)) {
|
||||
Some(((date, time), d)) => iso(&d, date, time),
|
||||
None => n.to_string(),
|
||||
@@ -597,6 +611,24 @@ pub(crate) fn esc(s: &str) -> String {
|
||||
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
|
||||
/// [`esc`], or `None` once the escaped text would pass `limit` bytes (nothing larger is built).
|
||||
fn esc_within(s: &str, limit: usize) -> Option<String> {
|
||||
let mut out = String::with_capacity(s.len().min(limit));
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
c => out.push(c),
|
||||
}
|
||||
if out.len() > limit {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn html_cell_css(wb: &Workbook, sh: &Sheet, c: CellRef, dir: CellDir) -> String {
|
||||
let st = wb.styles.get(sh.style_id(c));
|
||||
let mut css = String::new();
|
||||
@@ -672,6 +704,30 @@ fn table_dir(sh: &Sheet) -> &'static str {
|
||||
const CLIPBOARD_HTML_MAX_BYTES: usize = 4 * 1024 * 1024;
|
||||
const CLIPBOARD_HTML_MAX_CELLS: u64 = 10_000;
|
||||
|
||||
/// Most cells the merge index holds: one entry per merged cell inside the exported range.
|
||||
const MERGE_INDEX_MAX_ENTRIES: usize = 1_000_000;
|
||||
|
||||
/// The merge covering each cell of `rect`, as `Sheet::merge_at` would find it (the first in the
|
||||
/// sheet's list), built once so a table lookup is one probe instead of a scan of every merge.
|
||||
/// `None` when overlapping merges would cost more than a few visits per cell of `rect`, or merged
|
||||
/// cells fill more than [`MERGE_INDEX_MAX_ENTRIES`]: a hostile file's merge list can't multiply the
|
||||
/// work or the memory of a bounded export.
|
||||
fn merge_index(sh: &Sheet, rect: RangeRef) -> Option<std::collections::HashMap<CellRef, RangeRef>> {
|
||||
let mut map = std::collections::HashMap::new();
|
||||
let mut left = rect.count().saturating_mul(4);
|
||||
for m in &sh.merges {
|
||||
let Some(part) = m.intersection(&rect) else { continue };
|
||||
left = left.checked_sub(part.count())?;
|
||||
for c in part.iter() {
|
||||
map.entry(c).or_insert(*m);
|
||||
}
|
||||
if map.len() > MERGE_INDEX_MAX_ENTRIES {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(map)
|
||||
}
|
||||
|
||||
/// An HTML fragment for the same range as `plain_text`, previously produced by
|
||||
/// `display::range_text`. Large copies keep plain text only; never return a partial table.
|
||||
pub(crate) fn range_html(wb: &Workbook, sheet: usize, r: RangeRef, plain_text: &str) -> Option<String> {
|
||||
@@ -680,6 +736,7 @@ pub(crate) fn range_html(wb: &Workbook, sheet: usize, r: RangeRef, plain_text: &
|
||||
return None;
|
||||
}
|
||||
let sh = wb.sheet(sheet)?;
|
||||
let merges = merge_index(sh, r)?;
|
||||
let mut out = ClipboardHtml(String::new());
|
||||
write!(out, "<table{} style=\"border-collapse:collapse;font-family:Calibri,Carlito,Arial,sans-serif;font-size:11pt\">", table_dir(sh)).ok()?;
|
||||
for row in r.start.row..=r.end.row {
|
||||
@@ -689,7 +746,7 @@ pub(crate) fn range_html(wb: &Workbook, sheet: usize, r: RangeRef, plain_text: &
|
||||
out.write_str("<tr>").ok()?;
|
||||
for col in r.start.col..=r.end.col {
|
||||
let c = CellRef::new(row, col);
|
||||
let span = if let Some(merged) = sh.merges.iter().find(|m| m.contains(c)).and_then(|m| m.intersection(&r)) {
|
||||
let span = if let Some(merged) = merges.get(&c).and_then(|m| m.intersection(&r)) {
|
||||
let first_row = (merged.start.row..=merged.end.row).find(|&row| !sh.is_row_hidden(row))?;
|
||||
if c != CellRef::new(first_row, merged.start.col) {
|
||||
continue;
|
||||
@@ -752,6 +809,14 @@ const HTML_EXPORT_MAX_ROWS: u32 = 100_001;
|
||||
/// tall, wide one) would otherwise be gigabytes of `<td></td>` before finishing (#2387).
|
||||
const HTML_EXPORT_MAX_CELLS: u64 = 4_000_000;
|
||||
|
||||
/// Most bytes of one web page. Every cell may hold long text (shared across many cells in the
|
||||
/// file), so the cell count alone doesn't bound the page; past this the export fails instead.
|
||||
const HTML_EXPORT_MAX_BYTES: usize = 256 * 1024 * 1024;
|
||||
|
||||
fn html_too_big() -> EngineError {
|
||||
EngineError::Other(format!("the web page would be over {} MiB. Save as .xlsx or CSV instead.", HTML_EXPORT_MAX_BYTES / (1024 * 1024)))
|
||||
}
|
||||
|
||||
/// A sheet as a standalone HTML table (Save as Web Page). A sheet with pictures gets the sheet's
|
||||
/// own column widths and row heights so each picture and chart (inline SVG) can sit at its anchor.
|
||||
pub fn to_html(wb: &Workbook, sheet: usize) -> Result<String> {
|
||||
@@ -794,6 +859,24 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result<String> {
|
||||
r.width()
|
||||
)));
|
||||
}
|
||||
let merges = merge_index(sh, r)
|
||||
.ok_or_else(|| EngineError::Other("the sheet's merged cells overlap too much, or cover too many cells, for a web page.".into()))?;
|
||||
// Visible rows and columns of the table before each line, so a merge's span is counted
|
||||
// in constant time whatever the file says its size is.
|
||||
let visible_before = |hidden: &dyn Fn(u32) -> bool, from: u32, to: u32| {
|
||||
let mut v = Vec::with_capacity((to - from) as usize + 2);
|
||||
v.push(0u32);
|
||||
for line in from..=to {
|
||||
v.push(v.last().copied().unwrap_or(0) + u32::from(!hidden(line)));
|
||||
}
|
||||
v
|
||||
};
|
||||
let rows_before = visible_before(&|y| sh.is_row_hidden(y), r.start.row, r.end.row);
|
||||
let cols_before = visible_before(&|x| sh.is_col_hidden(x), r.start.col, r.end.col);
|
||||
let visible = |before: &[u32], from: u32, a: u32, b: u32| {
|
||||
let at = |line: u32| before.get((line - from) as usize).copied().unwrap_or(0);
|
||||
at(b + 1) - at(a)
|
||||
};
|
||||
if fixed {
|
||||
out.push_str("<colgroup>");
|
||||
for col in (r.start.col..=r.end.col).filter(|&c| !sh.is_col_hidden(c)) {
|
||||
@@ -812,27 +895,31 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result<String> {
|
||||
}
|
||||
for col in r.start.col..=r.end.col {
|
||||
let c = CellRef::new(row, col);
|
||||
if sh.merges.iter().any(|m| m.contains(c) && m.start != c) || (fixed && sh.is_col_hidden(col)) {
|
||||
let merge = merges.get(&c);
|
||||
if merge.is_some_and(|m| m.start != c) || (fixed && sh.is_col_hidden(col)) {
|
||||
continue;
|
||||
}
|
||||
let text = crate::display::cell_text(wb, sh, c);
|
||||
let dir = CellDir::of(sh, c, &text);
|
||||
let css = html_cell_css(wb, sh, c, dir);
|
||||
let span = sh
|
||||
.merges
|
||||
.iter()
|
||||
.find(|m| m.start == c)
|
||||
let span = merge
|
||||
.map(|m| {
|
||||
if fixed {
|
||||
let rows = (m.start.row..=m.end.row).filter(|&y| !sh.is_row_hidden(y)).count();
|
||||
let cols = (m.start.col..=m.end.col).filter(|&x| !sh.is_col_hidden(x)).count();
|
||||
// Spans count the table's visible lines: the merge is clipped to the table.
|
||||
let rows = visible(&rows_before, r.start.row, m.start.row, m.end.row.min(r.end.row));
|
||||
let cols = visible(&cols_before, r.start.col, m.start.col, m.end.col.min(r.end.col));
|
||||
format!(" rowspan=\"{rows}\" colspan=\"{cols}\"")
|
||||
} else {
|
||||
format!(" rowspan=\"{}\" colspan=\"{}\"", m.height(), m.width())
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let _ = write!(out, "<td style=\"{css}\"{}{span}>{}</td>", dir.attr(), esc(&text));
|
||||
// The text is escaped only as far as the page still has room for it.
|
||||
let Some(escaped) = esc_within(&text, HTML_EXPORT_MAX_BYTES.saturating_sub(out.len())) else { return Err(html_too_big()) };
|
||||
let _ = write!(out, "<td style=\"{css}\"{}{span}>{escaped}</td>", dir.attr());
|
||||
if out.len() > HTML_EXPORT_MAX_BYTES {
|
||||
return Err(html_too_big());
|
||||
}
|
||||
}
|
||||
out.push_str("</tr>");
|
||||
}
|
||||
@@ -840,34 +927,48 @@ pub fn to_html(wb: &Workbook, sheet: usize) -> Result<String> {
|
||||
out.push_str("</table>");
|
||||
if fixed {
|
||||
// Sheet units are CSS pixels; the table starts at the used range, not at A1.
|
||||
let (left, top) = used.map_or((0.0, 0.0), |r| (sh.col_left(r.start.col), sh.row_top(r.start.row)));
|
||||
// Indexed positions: each anchor would otherwise sum every custom row and column before it.
|
||||
let ri = LineIndex::new(&sh.rows, sh.default_row_height, gridcraft_core::MAX_ROWS);
|
||||
let ci = LineIndex::new(&sh.cols, sh.default_col_width, gridcraft_core::MAX_COLS);
|
||||
let (left, top) = used.map_or((0.0, 0.0), |r| (ci.start(r.start.col), ri.start(r.start.row)));
|
||||
let place = |a: &gridcraft_model::Anchor| {
|
||||
format!(
|
||||
"position:absolute;left:{}px;top:{}px;width:{}px;height:{}px",
|
||||
sh.col_left(a.cell.col) - left + f64::from(a.dx),
|
||||
sh.row_top(a.cell.row) - top + f64::from(a.dy),
|
||||
ci.start(a.cell.col) - left + f64::from(a.dx),
|
||||
ri.start(a.cell.row) - top + f64::from(a.dy),
|
||||
a.width.max(0.0),
|
||||
a.height.max(0.0)
|
||||
)
|
||||
};
|
||||
for im in &sh.images {
|
||||
let _ = write!(
|
||||
out,
|
||||
"<img src=\"data:{};base64,{}\" alt=\"{}\" style=\"{}\">",
|
||||
esc(&im.mime),
|
||||
base64_encode(&im.data),
|
||||
esc(&im.alt),
|
||||
place(&im.anchor)
|
||||
);
|
||||
// The base64 text is a third longer than the picture: refused before it is built.
|
||||
if out.len().saturating_add(im.data.len().div_ceil(3).saturating_mul(4)) > HTML_EXPORT_MAX_BYTES {
|
||||
return Err(html_too_big());
|
||||
}
|
||||
// The attributes are escaped only as far as the page has room for them.
|
||||
let room = HTML_EXPORT_MAX_BYTES.saturating_sub(out.len());
|
||||
let (Some(mime), Some(alt)) = (esc_within(&im.mime, room), esc_within(&im.alt, room)) else { return Err(html_too_big()) };
|
||||
let _ = write!(out, "<img src=\"data:{mime};base64,{}\" alt=\"{alt}\" style=\"{}\">", base64_encode(&im.data), place(&im.anchor));
|
||||
if out.len() > HTML_EXPORT_MAX_BYTES {
|
||||
return Err(html_too_big());
|
||||
}
|
||||
}
|
||||
let mut svg_budget = crate::chart_svg::MAX_PAGE_SVG_BYTES;
|
||||
for ch in &sh.charts {
|
||||
if let Some(svg) = crate::chart_svg::chart_svg(wb, sheet, ch, &place(&ch.anchor)) {
|
||||
if let Some(svg) = crate::chart_svg::chart_svg(wb, sheet, ch, &place(&ch.anchor), svg_budget)? {
|
||||
svg_budget -= svg.len();
|
||||
out.push_str(&svg);
|
||||
if out.len() > HTML_EXPORT_MAX_BYTES {
|
||||
return Err(html_too_big());
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str("</div>");
|
||||
}
|
||||
out.push_str("</body></html>");
|
||||
if out.len() > HTML_EXPORT_MAX_BYTES {
|
||||
return Err(html_too_big());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -879,12 +980,12 @@ thread_local! {
|
||||
/// While one is alive, [`read_file`] and [`write_file`] on this thread refuse every path: a
|
||||
/// stored script's steps run inside one (#2178), so no command reaches the file system from a
|
||||
/// script whatever its parameters are called. Dropping it (also on unwind) lifts the refusal.
|
||||
pub(crate) struct ScriptSandbox(());
|
||||
pub struct ScriptSandbox(std::marker::PhantomData<*const ()>);
|
||||
|
||||
impl ScriptSandbox {
|
||||
pub(crate) fn enter() -> Self {
|
||||
pub fn enter() -> Self {
|
||||
let _ = SCRIPT_SANDBOX.try_with(|c| c.set(c.get().saturating_add(1)));
|
||||
ScriptSandbox(())
|
||||
ScriptSandbox(std::marker::PhantomData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -912,6 +1013,12 @@ const MAX_READ_BYTES: u64 = 2 * 1024 * 1024 * 1024;
|
||||
pub fn read_file(path: &str) -> Result<Vec<u8>> {
|
||||
use std::io::Read;
|
||||
script_sandbox_check(path)?;
|
||||
// Checked before opening: opening a FIFO nobody writes to blocks the calling (GUI) thread.
|
||||
// shortcut: a FIFO swapped in between this check and the open still blocks; close that with
|
||||
// O_NONBLOCK when the crate takes a libc dependency.
|
||||
if !std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?.is_file() {
|
||||
return Err(EngineError::Other(format!("{path}: only regular files can be opened")));
|
||||
}
|
||||
let f = std::fs::File::open(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
// A symlink to an endless device reports length 0 but streams forever, so metadata
|
||||
// alone never bounds the read: require a real file and cap the bytes actually taken.
|
||||
@@ -943,18 +1050,37 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
// link's target; and it inherits the destination's access restrictions, so saving a
|
||||
// private workbook doesn't widen who can read it.
|
||||
let tmp = format!("{path}.sctmp");
|
||||
let prior = std::fs::metadata(path).ok().map(|m| m.permissions());
|
||||
let prior_meta = std::fs::metadata(path).ok();
|
||||
let prior = prior_meta.as_ref().map(std::fs::Metadata::permissions);
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create_new(true);
|
||||
// Over an existing file the replacement starts owner-only, whatever the umask: its group is
|
||||
// still the saving user's primary one until it is changed below, and a member of that group
|
||||
// must not be able to open it (and keep the descriptor) before then.
|
||||
#[cfg(unix)]
|
||||
if let Some(p) = &prior {
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
opts.mode(p.mode());
|
||||
if prior.is_some() {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
opts.mode(0o600);
|
||||
}
|
||||
let mut f = opts.open(&tmp).map_err(|e| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
EngineError::Other(format!("{path}: {e}"))
|
||||
})?;
|
||||
// The replacement belongs to the saving user's primary group: restore the old file's group and
|
||||
// then its mode. When the group can't be restored its access is dropped rather than handed to a
|
||||
// different group; failing to read that back is a failed save.
|
||||
#[cfg(unix)]
|
||||
if let Some(m) = &prior_meta {
|
||||
use std::os::unix::fs::{MetadataExt, PermissionsExt};
|
||||
let _ = std::os::unix::fs::fchown(&f, None, Some(m.gid()));
|
||||
let group_kept = f.metadata().map(|now| now.gid() == m.gid()).map_err(|e| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
EngineError::Other(format!("{path}: {e}"))
|
||||
})?;
|
||||
// Best effort: a file system that can't change modes keeps the owner-only one. Without the
|
||||
// group, the old owner and other bits are kept and the group, setuid and setgid bits dropped.
|
||||
let _ = f.set_permissions(std::fs::Permissions::from_mode(restored_mode(m.mode(), group_kept)));
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
if let Some(p) = &prior {
|
||||
let _ = f.set_permissions(p.clone());
|
||||
@@ -971,6 +1097,13 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The mode a saved file gets from the file it replaces (`old`): all of it when its group was
|
||||
/// restored; without the group, the owner and other bits only.
|
||||
#[cfg(unix)]
|
||||
fn restored_mode(old: u32, group_kept: bool) -> u32 {
|
||||
if group_kept { old & 0o7777 } else { old & 0o707 }
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn write_file(path: &str, _bytes: &[u8]) -> Result<()> {
|
||||
script_sandbox_check(path)?;
|
||||
|
||||
+37
-301
@@ -1,156 +1,57 @@
|
||||
//! SYLK (`.slk`) import: the symbolic link text format older spreadsheets and some legacy systems
|
||||
//! export. One record per line, fields separated by `;` (`;;` is a literal semicolon):
|
||||
//!
|
||||
//! - `ID` starts the file; `E` ends it.
|
||||
//! - `C;Y<row>;X<col>;K<value>;E<formula>` is a cell. `Y` and `X` may be left out to stay on the
|
||||
//! current row or column. `K` is a number, `"text"`, `TRUE`/`FALSE` or an error; `E` is a
|
||||
//! formula in R1C1 notation; `S;R<row>;C<col>` shares the formula of another cell.
|
||||
//! - `P;P<code>` adds a number format to the file's list; `F;P<n>;Y<row>;X<col>` gives a cell the
|
||||
//! n-th format (0-based), and `F;W<first> <last> <width>` sets column widths in characters.
|
||||
//!
|
||||
//! Other records (fonts, options, window settings) are skipped. Formulas are converted to A1 with
|
||||
//! the same relative and absolute references, as Excel does when it opens the file.
|
||||
//! SYLK (`.slk`) import. `craft_csv::sylk` reads the file (values, formulas rewritten from R1C1 to
|
||||
//! A1, number formats, column widths); this module makes it a one-sheet workbook: formulas parsed
|
||||
//! (a formula GridCraft can't parse is skipped and counted), error values recognised, formats
|
||||
//! interned as styles and widths converted from characters.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, Value, col_to_letters};
|
||||
use craft_csv::sylk;
|
||||
use gridcraft_core::{CellError, CellRef, Value};
|
||||
use gridcraft_model::{Cell, Formula, NumFmt, Sheet, Style, Workbook};
|
||||
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
/// The largest SYLK file read, as for other text imports: past it the file is refused.
|
||||
const MAX_BYTES: usize = 512 << 20;
|
||||
/// Column-width records kept from one file; each can cover every column.
|
||||
const MAX_WIDTH_RECORDS: usize = 1024;
|
||||
|
||||
/// Reads a SYLK file into a one-sheet workbook. Returns the workbook and its warnings.
|
||||
pub fn read(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
|
||||
if bytes.len() > MAX_BYTES {
|
||||
return Err(EngineError::Other(format!("We can't open '{name}': the SYLK file is larger than {} MB.", MAX_BYTES >> 20)));
|
||||
}
|
||||
let text = match std::str::from_utf8(bytes) {
|
||||
Ok(s) => s.to_string(),
|
||||
// Older exports are in a single-byte code page: keep each byte as its Latin-1 character.
|
||||
Err(_) => bytes.iter().map(|&b| char::from(b)).collect(),
|
||||
};
|
||||
let text = text.strip_prefix('\u{feff}').unwrap_or(&text);
|
||||
if !text.starts_with("ID") {
|
||||
return Err(EngineError::Other(format!("We can't open '{name}': it isn't a SYLK file (it doesn't start with an ID record).")));
|
||||
}
|
||||
let file = sylk::read(bytes).map_err(|e| match e {
|
||||
sylk::Error::TooLarge => EngineError::Other(format!("We can't open '{name}': the SYLK file is larger than {} MB.", sylk::MAX_BYTES >> 20)),
|
||||
_ => EngineError::Other(format!("We can't open '{name}': it isn't a SYLK file (it doesn't start with an ID record).")),
|
||||
})?;
|
||||
let mut wb = Workbook::new();
|
||||
let mut sheet = Sheet::new("Sheet1");
|
||||
let mut formats: Vec<String> = Vec::new();
|
||||
// Cells' number formats by index into `formats`, applied once every format is known.
|
||||
let mut cell_formats: Vec<(CellRef, usize)> = Vec::new();
|
||||
let mut formulas: HashMap<CellRef, String> = HashMap::new();
|
||||
let mut widths: Vec<(u32, u32, f64)> = Vec::new();
|
||||
let (mut row, mut col) = (0u32, 0u32);
|
||||
let mut skipped = 0usize;
|
||||
for line in text.lines() {
|
||||
let fields = split_fields(line);
|
||||
let Some((kind, rest)) = fields.split_first() else { continue };
|
||||
match kind.as_str() {
|
||||
"C" => {
|
||||
let (mut value, mut formula, mut shared) = (None, None, None::<(Option<u32>, Option<u32>)>);
|
||||
let mut bad = false;
|
||||
for f in rest {
|
||||
let (tag, arg) = f.split_at(f.chars().next().map_or(0, char::len_utf8));
|
||||
match tag {
|
||||
"Y" => match position(arg, MAX_ROWS) {
|
||||
Some(r) => row = r,
|
||||
None => bad = true,
|
||||
},
|
||||
"X" => match position(arg, MAX_COLS) {
|
||||
Some(c) => col = c,
|
||||
None => bad = true,
|
||||
},
|
||||
"K" => value = Some(parse_value(arg)),
|
||||
"E" => formula = Some(arg.to_string()),
|
||||
"S" => shared = Some((None, None)),
|
||||
// In a shared-formula cell, R and C name the cell whose formula it shares.
|
||||
"R" if shared.is_some() => shared = shared.map(|(_, c)| (position(arg, MAX_ROWS), c)),
|
||||
"C" if shared.is_some() => shared = shared.map(|(r, _)| (r, position(arg, MAX_COLS))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if bad {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
let at = CellRef::new(row, col);
|
||||
if let Some((Some(r), Some(c))) = shared {
|
||||
formula = formulas.get(&CellRef::new(r, c)).cloned();
|
||||
}
|
||||
let mut cell = sheet.cell(at).cloned().unwrap_or_else(|| Cell::value(Value::Empty));
|
||||
if let Some(v) = value {
|
||||
cell.value = v;
|
||||
}
|
||||
if let Some(r1c1) = formula {
|
||||
let a1 = r1c1_formula_to_a1(&r1c1, at);
|
||||
let f = Formula::new(&a1);
|
||||
if f.expr.is_some() {
|
||||
cell.formula = Some(Arc::new(f));
|
||||
formulas.insert(at, r1c1);
|
||||
} else {
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
sheet.set_cell(at, cell);
|
||||
let mut skipped = file.skipped;
|
||||
let mut styles = HashMap::new();
|
||||
for (&(row, col), c) in &file.cells {
|
||||
let value = match &c.value {
|
||||
sylk::Value::Number(n) => Value::Number(*n),
|
||||
sylk::Value::Text(s) => Value::Text(Arc::from(s.as_str())),
|
||||
sylk::Value::Bool(b) => Value::Bool(*b),
|
||||
sylk::Value::Error(s) => CellError::parse(s).map_or_else(|| Value::Text(Arc::from(s.as_str())), Value::Error),
|
||||
_ => Value::Empty,
|
||||
};
|
||||
let mut cell = Cell::value(value);
|
||||
if let Some(a1) = &c.formula {
|
||||
let f = Formula::new(a1);
|
||||
if f.expr.is_some() {
|
||||
cell.formula = Some(Arc::new(f));
|
||||
} else {
|
||||
skipped += 1;
|
||||
}
|
||||
"F" => {
|
||||
let mut format = None;
|
||||
for f in rest {
|
||||
let (tag, arg) = f.split_at(f.chars().next().map_or(0, char::len_utf8));
|
||||
match tag {
|
||||
"Y" => row = position(arg, MAX_ROWS).unwrap_or(row),
|
||||
"X" => col = position(arg, MAX_COLS).unwrap_or(col),
|
||||
"P" => format = arg.parse::<usize>().ok(),
|
||||
"W" => {
|
||||
let mut parts = arg.split_whitespace();
|
||||
if let (Some(first), Some(last), Some(width)) = (parts.next(), parts.next(), parts.next())
|
||||
&& let (Some(first), Some(last), Ok(width)) =
|
||||
(position(first, MAX_COLS), position(last, MAX_COLS), width.parse::<f64>())
|
||||
&& first <= last
|
||||
&& (0.0..=255.0).contains(&width)
|
||||
// A file of width records can't make the import loop over every column
|
||||
// again and again.
|
||||
&& widths.len() < MAX_WIDTH_RECORDS
|
||||
{
|
||||
widths.push((first, last, width));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(format) = format {
|
||||
cell_formats.push((CellRef::new(row, col), format));
|
||||
}
|
||||
}
|
||||
// Number formats only: `P;F…` and `P;E…` are fonts.
|
||||
"P" => {
|
||||
if let Some(code) = rest.first().and_then(|f| f.strip_prefix('P')) {
|
||||
formats.push(code.to_string());
|
||||
}
|
||||
}
|
||||
"E" => break,
|
||||
_ => {}
|
||||
}
|
||||
if let Some(code) = &c.format {
|
||||
cell.style = *styles
|
||||
.entry(code.as_str())
|
||||
.or_insert_with(|| wb.styles.intern(Style { num_fmt: NumFmt(Arc::from(code.as_str())), ..Style::default() }));
|
||||
}
|
||||
sheet.set_cell(CellRef::new(row, col), cell);
|
||||
}
|
||||
for (first, last, width) in widths {
|
||||
let size = crate::cmd::format::chars_to_points(width) as f32;
|
||||
for c in first..=last {
|
||||
for w in &file.column_widths {
|
||||
let size = crate::cmd::format::chars_to_points(w.width) as f32;
|
||||
for c in w.first..=w.last {
|
||||
sheet.cols.entry(c).or_default().size = Some(size);
|
||||
}
|
||||
}
|
||||
let mut wb = Workbook::new();
|
||||
let mut styles = HashMap::new();
|
||||
for (at, index) in cell_formats {
|
||||
let Some(code) = formats.get(index).filter(|code| !code.is_empty() && *code != "General") else { continue };
|
||||
let style = *styles.entry(index).or_insert_with(|| wb.styles.intern(Style { num_fmt: NumFmt(Arc::from(code.as_str())), ..Style::default() }));
|
||||
let mut cell = sheet.cell(at).cloned().unwrap_or_else(|| Cell::value(Value::Empty));
|
||||
cell.style = style;
|
||||
sheet.set_cell(at, cell);
|
||||
}
|
||||
if let Some(first) = wb.sheet_mut(0) {
|
||||
sheet.name = std::mem::take(&mut first.name);
|
||||
*first = sheet;
|
||||
@@ -161,168 +62,3 @@ pub fn read(name: &str, bytes: &[u8]) -> Result<(Workbook, Vec<String>)> {
|
||||
}
|
||||
Ok((wb, warnings))
|
||||
}
|
||||
|
||||
/// A record's fields: `;` separates them and `;;` is a literal semicolon.
|
||||
fn split_fields(line: &str) -> Vec<String> {
|
||||
let mut fields = vec![String::new()];
|
||||
let mut chars = line.trim_end_matches('\r').chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == ';' {
|
||||
if chars.peek() == Some(&';') {
|
||||
chars.next();
|
||||
if let Some(f) = fields.last_mut() {
|
||||
f.push(';');
|
||||
}
|
||||
} else {
|
||||
fields.push(String::new());
|
||||
}
|
||||
} else if let Some(f) = fields.last_mut() {
|
||||
f.push(c);
|
||||
}
|
||||
}
|
||||
fields
|
||||
}
|
||||
|
||||
/// A 1-based row or column number → 0-based, within `max`.
|
||||
fn position(arg: &str, max: u32) -> Option<u32> {
|
||||
arg.trim().parse::<u32>().ok().filter(|n| (1..=max).contains(n)).map(|n| n - 1)
|
||||
}
|
||||
|
||||
/// A `K` value: `"text"` (everything between the first and the last quote, as written), a
|
||||
/// boolean, an error or a number.
|
||||
fn parse_value(arg: &str) -> Value {
|
||||
if let Some(text) = arg.strip_prefix('"') {
|
||||
let text = text.strip_suffix('"').unwrap_or(text);
|
||||
return Value::Text(Arc::from(text));
|
||||
}
|
||||
if arg.eq_ignore_ascii_case("TRUE") || arg.eq_ignore_ascii_case("FALSE") {
|
||||
return Value::Bool(arg.eq_ignore_ascii_case("TRUE"));
|
||||
}
|
||||
if let Some(e) = CellError::parse(arg) {
|
||||
return Value::Error(e);
|
||||
}
|
||||
match arg.trim().parse::<f64>() {
|
||||
Ok(n) if n.is_finite() => Value::Number(n),
|
||||
_ => Value::Text(Arc::from(arg)),
|
||||
}
|
||||
}
|
||||
|
||||
/// One part of an R1C1 reference: `R`/`C` followed by an absolute number, a bracketed offset or
|
||||
/// nothing (the cell's own row or column).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Part {
|
||||
Absolute(u32),
|
||||
Relative(i64),
|
||||
}
|
||||
|
||||
impl Part {
|
||||
/// The 0-based index and whether it is absolute, or `None` when it falls off the sheet.
|
||||
fn resolve(self, base: u32, max: u32) -> Option<(u32, bool)> {
|
||||
match self {
|
||||
Part::Absolute(n) => (1..=max).contains(&n).then(|| (n - 1, true)),
|
||||
Part::Relative(k) => i64::from(base).checked_add(k).and_then(|v| u32::try_from(v).ok()).filter(|v| *v < max).map(|v| (v, false)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses the number after `R` or `C` at `i`: digits, `[offset]` or nothing. Returns the part and
|
||||
/// the index after it, or `None` when the text isn't a reference part.
|
||||
fn parse_part(chars: &[char], i: usize) -> Option<(Part, usize)> {
|
||||
match chars.get(i) {
|
||||
Some('[') => {
|
||||
let close = chars[i..].iter().position(|&c| c == ']')? + i;
|
||||
let inner: String = chars[i + 1..close].iter().collect();
|
||||
Some((Part::Relative(inner.parse().ok()?), close + 1))
|
||||
}
|
||||
Some(c) if c.is_ascii_digit() => {
|
||||
let end = chars[i..].iter().position(|c| !c.is_ascii_digit()).map_or(chars.len(), |p| p + i);
|
||||
let n: String = chars[i..end].iter().collect();
|
||||
Some((Part::Absolute(n.parse().unwrap_or(u32::MAX)), end))
|
||||
}
|
||||
_ => Some((Part::Relative(0), i)),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_word_char(c: char) -> bool {
|
||||
c.is_alphanumeric() || matches!(c, '_' | '.' | '\\' | '?' | '$')
|
||||
}
|
||||
|
||||
/// Rewrites the R1C1 references in a SYLK formula as A1 references for the cell at `at`:
|
||||
/// `RC[-1]` → `B2`, `R1C1` → `$A$1`, `R[-2]C:R[-1]C` → `D2:D3`, a whole column `C4` → `$D:$D` and
|
||||
/// a whole row `R3` → `$3:$3`. Text in quotes and quoted sheet names are left alone; a reference
|
||||
/// off the sheet becomes `#REF!`.
|
||||
pub(crate) fn r1c1_formula_to_a1(formula: &str, at: CellRef) -> String {
|
||||
let chars: Vec<char> = formula.chars().collect();
|
||||
let mut out = String::with_capacity(formula.len());
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
let c = chars[i];
|
||||
if c == '"' || c == '\'' {
|
||||
// Copy the quoted run as it is; a doubled quote stays inside it.
|
||||
let mut j = i + 1;
|
||||
while j < chars.len() && !(chars[j] == c && chars.get(j + 1) != Some(&c)) {
|
||||
j += if chars[j] == c { 2 } else { 1 };
|
||||
}
|
||||
let stop = (j + 1).min(chars.len());
|
||||
out.extend(&chars[i..stop]);
|
||||
i = stop;
|
||||
continue;
|
||||
}
|
||||
let starts_word = i == 0 || !is_word_char(chars[i - 1]);
|
||||
if starts_word
|
||||
&& matches!(c, 'R' | 'r' | 'C' | 'c')
|
||||
&& let Some((text, next)) = reference_at(&chars, i, at)
|
||||
{
|
||||
out.push_str(&text);
|
||||
i = next;
|
||||
continue;
|
||||
}
|
||||
// Copy the rest of a word as it is, so `ROUND` or `Sheet2` isn't read as a reference.
|
||||
if is_word_char(c) {
|
||||
let end = (i..chars.len()).find(|&j| !is_word_char(chars[j])).unwrap_or(chars.len());
|
||||
out.extend(&chars[i..end]);
|
||||
i = end;
|
||||
} else {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The A1 text of the R1C1 reference starting at `i`, and the index after it, or `None` when the
|
||||
/// word there isn't a reference (a function or a name).
|
||||
fn reference_at(chars: &[char], i: usize, at: CellRef) -> Option<(String, usize)> {
|
||||
let mut j = i;
|
||||
let mut row = None;
|
||||
let mut col = None;
|
||||
if matches!(chars.get(j), Some('R' | 'r')) {
|
||||
let (part, next) = parse_part(chars, j + 1)?;
|
||||
row = Some(part);
|
||||
j = next;
|
||||
}
|
||||
if matches!(chars.get(j), Some('C' | 'c')) {
|
||||
let (part, next) = parse_part(chars, j + 1)?;
|
||||
col = Some(part);
|
||||
j = next;
|
||||
}
|
||||
if row.is_none() && col.is_none() {
|
||||
return None;
|
||||
}
|
||||
// A reference ends the word: `ROUND(`, `RATE` or `R2D2` are names.
|
||||
if chars.get(j).is_some_and(|&c| is_word_char(c) || c == '(' || c == '[' || c == '!') {
|
||||
return None;
|
||||
}
|
||||
let row_text = |p: Part| p.resolve(at.row, MAX_ROWS).map(|(r, abs)| format!("{}{}", if abs { "$" } else { "" }, r + 1));
|
||||
let col_text = |p: Part| p.resolve(at.col, MAX_COLS).map(|(c, abs)| format!("{}{}", if abs { "$" } else { "" }, col_to_letters(c)));
|
||||
// One side of a range (`R2:R4`, `C1:C3`) is written alone; a lone row or column is the whole of it.
|
||||
let prev = chars[..i].iter().rev().find(|c| !c.is_whitespace());
|
||||
let in_range = prev == Some(&':') || chars[j..].iter().find(|c| !c.is_whitespace()) == Some(&':');
|
||||
let text = match (row, col) {
|
||||
(Some(r), Some(c)) => row_text(r).zip(col_text(c)).map(|(r, c)| format!("{c}{r}")),
|
||||
(Some(r), None) => row_text(r).map(|r| if in_range { r } else { format!("{r}:{r}") }),
|
||||
(None, Some(c)) => col_text(c).map(|c| if in_range { c } else { format!("{c}:{c}") }),
|
||||
(None, None) => None,
|
||||
};
|
||||
Some((text.unwrap_or_else(|| "#REF!".into()), j))
|
||||
}
|
||||
|
||||
@@ -51,6 +51,14 @@ fn save_keeps_the_destination_access_restrictions() {
|
||||
write_file(dest.to_str().unwrap(), b"v2").unwrap();
|
||||
let mode = std::fs::metadata(&dest).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600);
|
||||
// The mode is set outright from the old file's, not through the umask: group and other access
|
||||
// it had stay as they were.
|
||||
for old in [0o640, 0o664] {
|
||||
std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(old)).unwrap();
|
||||
write_file(dest.to_str().unwrap(), b"v3").unwrap();
|
||||
assert_eq!(std::fs::metadata(&dest).unwrap().permissions().mode() & 0o777, old);
|
||||
}
|
||||
assert!(!dir.join("private.xlsx.sctmp").exists());
|
||||
// And a brand-new file is created with the restrictive, not permissive, default.
|
||||
let fresh = dir.join("new.xlsx");
|
||||
write_file(fresh.to_str().unwrap(), b"v").unwrap();
|
||||
@@ -59,6 +67,17 @@ fn save_keeps_the_destination_access_restrictions() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Without the old group, a saved file keeps the old owner and other bits and nothing for a group.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn restored_mode_drops_the_group_and_special_bits_when_the_group_is_lost() {
|
||||
assert_eq!(super::restored_mode(0o100_664, true), 0o664);
|
||||
assert_eq!(super::restored_mode(0o100_664, false), 0o604);
|
||||
assert_eq!(super::restored_mode(0o102_444, false), 0o404, "an original read-only file stays read-only, setgid is dropped");
|
||||
assert_eq!(super::restored_mode(0o104_755, false), 0o705, "setuid is dropped");
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn open_refuses_directories_and_unknowable_lengths() {
|
||||
@@ -351,6 +370,19 @@ fn script_sandbox_refuses_file_access_until_dropped() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The guard's drop lowers the counter of the thread it runs on, so it must not move between threads:
|
||||
/// naming the trait's method is ambiguous (a compile error) if `ScriptSandbox` is `Send`.
|
||||
#[test]
|
||||
fn script_sandbox_is_not_send() {
|
||||
trait AmbiguousIfSend<A> {
|
||||
fn check() {}
|
||||
}
|
||||
impl<T: ?Sized> AmbiguousIfSend<()> for T {}
|
||||
struct IsSend;
|
||||
impl<T: ?Sized + Send> AmbiguousIfSend<IsSend> for T {}
|
||||
<ScriptSandbox as AmbiguousIfSend<_>>::check();
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
@@ -731,7 +763,7 @@ fn sylk_r1c1_formulas_become_a1() {
|
||||
("R[-5]C", "#REF!"),
|
||||
("\"RC\"&R1C1", "\"RC\"&$A$1"),
|
||||
] {
|
||||
assert_eq!(sylk::r1c1_formula_to_a1(r1c1, b2), a1, "{r1c1}");
|
||||
assert_eq!(craft_csv::sylk::r1c1_formula_to_a1(r1c1, (b2.row, b2.col)), a1, "{r1c1}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -761,3 +793,91 @@ fn sylk_survives_huge_relative_offsets_and_width_floods() {
|
||||
let (wb, _) = open_bytes("widths.slk", &flood).unwrap();
|
||||
assert_eq!(wb.sheet(0).unwrap().value(CellRef::parse("A1").unwrap()), gridcraft_core::Value::Number(1.0));
|
||||
}
|
||||
|
||||
/// A FIFO with no writer would block an open forever: it is refused before it is opened.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn reading_a_fifo_is_refused_without_blocking() {
|
||||
let dir = std::env::temp_dir().join(format!("gridcraft-fifo-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let fifo = dir.join("pipe.xlsx");
|
||||
if !std::process::Command::new("mkfifo").arg(&fifo).status().is_ok_and(|s| s.success()) {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
return; // no mkfifo here
|
||||
}
|
||||
let err = read_file(fifo.to_str().unwrap()).unwrap_err().to_string();
|
||||
assert!(err.contains("only regular files"), "{err}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Merges far from what is copied or exported, in a hostile number, are indexed once instead of
|
||||
/// being scanned for every cell; a merge running past the table is clipped to it.
|
||||
#[test]
|
||||
fn html_merge_lookups_and_spans_stay_bounded() {
|
||||
let mut wb = book_with_picture("A1", (0, 0));
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
for i in 0..200_000u32 {
|
||||
sh.merges.push(RangeRef::new(CellRef::new(500_000 + i * 2, 5), CellRef::new(500_000 + i * 2, 6)));
|
||||
}
|
||||
sh.merges.push(RangeRef::new(CellRef::new(0, 0), CellRef::new(gridcraft_core::MAX_ROWS - 1, 1)));
|
||||
sh.set_value(CellRef::new(2, 1), gridcraft_core::Value::Number(2.0));
|
||||
let html = copied(&wb, "A1:C50");
|
||||
assert!(html.contains("rowspan=\"50\" colspan=\"2\""), "{html}");
|
||||
let page = to_html(&wb, 0).unwrap();
|
||||
// The table is A1:B3 (the used range): the whole-height merge spans its three rows only.
|
||||
assert!(page.contains("rowspan=\"3\" colspan=\"2\""), "{page}");
|
||||
// Merges stacked over one another past a few per cell are refused, not walked.
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
sh.merges = vec![RangeRef::new(CellRef::new(0, 0), CellRef::new(2, 1)); 100];
|
||||
assert!(to_html(&wb, 0).is_err());
|
||||
assert!(range_html(&wb, 0, RangeRef::parse("A1:B3").unwrap(), "").is_none());
|
||||
}
|
||||
|
||||
/// Thousands of pictures over thousands of custom rows: each is placed through a row index.
|
||||
#[test]
|
||||
fn html_export_places_many_pictures_over_many_custom_rows() {
|
||||
let mut wb = book_with_picture("A1", (0, 0));
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
for row in 0..50_000 {
|
||||
sh.rows.insert(row, gridcraft_model::LineInfo { size: Some(30.0), ..Default::default() });
|
||||
}
|
||||
let image = sh.images[0].clone();
|
||||
for i in 0..5_000u32 {
|
||||
let mut im = image.clone();
|
||||
im.anchor.cell = CellRef::new(40_000 + i, 0);
|
||||
sh.images.push(im);
|
||||
}
|
||||
let page = to_html(&wb, 0).unwrap();
|
||||
assert!(page.contains(&format!("top:{}px", 30 * 44_999)), "last picture placed by the index");
|
||||
}
|
||||
|
||||
/// One long text shown by many cells: the page fails at its byte limit instead of growing on.
|
||||
#[test]
|
||||
fn html_export_refuses_a_page_past_its_byte_limit() {
|
||||
let mut wb = Workbook::new();
|
||||
let sh = wb.sheet_mut(0).unwrap();
|
||||
let text = gridcraft_core::Value::text("x".repeat(32_000));
|
||||
for row in 0..10_000 {
|
||||
sh.set_value(CellRef::new(row, 0), text.clone());
|
||||
}
|
||||
let err = to_html(&wb, 0).unwrap_err();
|
||||
assert!(err.to_string().contains("MiB"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaping_stops_at_the_room_left() {
|
||||
assert_eq!(esc_within("a&b<c>\"d", 100).as_deref(), Some(esc("a&b<c>\"d").as_str()));
|
||||
assert_eq!(esc_within("&&&", 14), None, "three `&` are 15 bytes");
|
||||
assert_eq!(esc_within("&&&", 15).as_deref(), Some("&&&"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_merge_index_holds_a_bounded_number_of_cells() {
|
||||
let mut sh = Sheet::new("S");
|
||||
let rect = RangeRef::new(CellRef::new(0, 0), CellRef::new(1_100_000, 0));
|
||||
sh.merges.push(RangeRef::new(CellRef::new(0, 0), CellRef::new(999_999, 0)));
|
||||
assert_eq!(merge_index(&sh, rect).map(|m| m.len()), Some(1_000_000));
|
||||
sh.merges = vec![RangeRef::new(CellRef::new(0, 0), CellRef::new(1_000_000, 0))];
|
||||
assert!(merge_index(&sh, rect).is_none());
|
||||
}
|
||||
|
||||
@@ -237,6 +237,22 @@ const JOURNAL_MAX_ENTRIES: usize = 10_000;
|
||||
pub(crate) const JOURNAL_MAX_BYTES: usize = 64 * 1024 * 1024;
|
||||
const UNDO_BYTES_LIMIT: usize = 128 * 1024 * 1024;
|
||||
|
||||
/// Comment and hyperlink text of a sheet: a changed sheet's snapshot keeps its own copy of these
|
||||
/// strings (an edit deep-copies them), so they count against the history budget with the cells.
|
||||
fn sheet_text_bytes(sheet: &gridcraft_model::Sheet) -> usize {
|
||||
let comments = sheet.comments.values().fold(0usize, |sum, c| {
|
||||
// The vectors' own storage counts too: an empty reply still takes an element.
|
||||
let replies = c.replies.iter().fold(0usize, |n, (a, t)| n.saturating_add(a.len()).saturating_add(t.len()));
|
||||
let replies = replies.saturating_add(c.replies.capacity().saturating_mul(std::mem::size_of::<(String, String)>()));
|
||||
let dates = c.dates.iter().fold(0usize, |n, d| n.saturating_add(d.len()));
|
||||
let dates = dates.saturating_add(c.dates.capacity().saturating_mul(std::mem::size_of::<String>()));
|
||||
sum.saturating_add(std::mem::size_of::<gridcraft_model::Comment>() + c.author.len() + c.text.len() + replies + dates)
|
||||
});
|
||||
sheet.hyperlinks.values().fold(comments, |sum, h| {
|
||||
sum.saturating_add(std::mem::size_of::<gridcraft_model::Hyperlink>() + h.target.len() + h.tooltip.as_ref().map_or(0, String::len))
|
||||
})
|
||||
}
|
||||
|
||||
fn history_retained_bytes(older: &Workbook, newer: &Workbook) -> usize {
|
||||
let mut bytes = std::mem::size_of::<Workbook>();
|
||||
for (index, sheet) in older.sheets.iter().enumerate() {
|
||||
@@ -248,6 +264,7 @@ fn history_retained_bytes(older: &Workbook, newer: &Workbook) -> usize {
|
||||
let empty = gridcraft_model::CellStore::new();
|
||||
let next = newer.sheets.get(index).map_or(&empty, |next| &next.cells);
|
||||
bytes = bytes.saturating_add(sheet.cells.retained_bytes_against(next));
|
||||
bytes = bytes.saturating_add(sheet_text_bytes(sheet));
|
||||
}
|
||||
// Snapshots share the macro list until a change to it; a snapshot with its own list keeps it.
|
||||
if !older.macros.shares(&newer.macros) {
|
||||
|
||||
@@ -440,7 +440,7 @@ pub fn infer_input(wb: &Workbook, sheet: usize, r: RangeRef) -> NamedInput {
|
||||
Some(match v {
|
||||
Value::Empty => return None,
|
||||
Value::Text(t) if t.is_empty() => return None,
|
||||
Value::Number(_) if crate::display::number_format(wb.styles.get(sh.style_id(c)).num_fmt.as_str()).is_date() => ColType::Date,
|
||||
Value::Number(_) if crate::display::parsed(wb.styles.get(sh.style_id(c)).num_fmt.as_str()).is_date() => ColType::Date,
|
||||
Value::Number(_) => ColType::Number,
|
||||
Value::Text(_) => ColType::Text,
|
||||
Value::Bool(_) => ColType::Bool,
|
||||
|
||||
@@ -335,7 +335,7 @@ fn check_input(wb: &Workbook, input: &gridcraft_model::NamedInput, place: Place)
|
||||
gridcraft_model::ColType::Number => v.is_number(),
|
||||
gridcraft_model::ColType::Text => v.is_text(),
|
||||
gridcraft_model::ColType::Bool => matches!(v, Value::Bool(_)),
|
||||
gridcraft_model::ColType::Date => v.is_number() && crate::display::number_format(&date()).is_date(),
|
||||
gridcraft_model::ColType::Date => v.is_number() && crate::display::parsed(&date()).is_date(),
|
||||
};
|
||||
if !ok {
|
||||
wrong.push((c, holds(&v, ty)));
|
||||
@@ -1281,9 +1281,18 @@ fn content_differs(b: Option<&Cell>, a: Option<&Cell>) -> bool {
|
||||
/// Objects added, changed and removed between two versions of a sheet.
|
||||
fn objects(b: &Sheet, a: &Sheet) -> (usize, usize, usize) {
|
||||
fn count<T: PartialEq>(b: &[T], a: &[T], id: impl Fn(&T) -> u32) -> (usize, usize, usize) {
|
||||
let added = a.iter().filter(|x| !b.iter().any(|y| id(y) == id(x))).count();
|
||||
let removed = b.iter().filter(|x| !a.iter().any(|y| id(y) == id(x))).count();
|
||||
let changed = b.iter().filter(|x| a.iter().any(|y| id(y) == id(x) && y != *x)).count();
|
||||
// Objects grouped by id, so each lookup is a map probe instead of a scan of the list.
|
||||
fn group<'a, T>(v: &'a [T], id: &impl Fn(&T) -> u32) -> std::collections::HashMap<u32, Vec<&'a T>> {
|
||||
let mut m: std::collections::HashMap<u32, Vec<&T>> = std::collections::HashMap::new();
|
||||
for x in v {
|
||||
m.entry(id(x)).or_default().push(x);
|
||||
}
|
||||
m
|
||||
}
|
||||
let (gb, ga) = (group(b, &id), group(a, &id));
|
||||
let added = a.iter().filter(|x| !gb.contains_key(&id(x))).count();
|
||||
let removed = b.iter().filter(|x| !ga.contains_key(&id(x))).count();
|
||||
let changed = b.iter().filter(|x| ga.get(&id(x)).is_some_and(|ys| ys.iter().any(|y| *y != *x))).count();
|
||||
(added, changed, removed)
|
||||
}
|
||||
let parts = [
|
||||
|
||||
@@ -182,7 +182,7 @@ pub fn read_source(wb: &Workbook, text: &str, default_sheet: usize) -> Result<So
|
||||
for (n, a) in numeric.iter_mut().zip(any.iter()) {
|
||||
*n = *n && *a;
|
||||
}
|
||||
let dates = numeric.iter().zip(formats.iter()).map(|(n, f)| *n && crate::display::number_format(f).is_date()).collect();
|
||||
let dates = numeric.iter().zip(formats.iter()).map(|(n, f)| *n && crate::display::parsed(f).is_date()).collect();
|
||||
Ok(Source { sheet: si, range: r, headers, formats, numeric, dates, rows })
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,10 @@ fn rows_ref(sheet: &str, origin: CellRef, col: u32, rows: &[u32]) -> Option<Stri
|
||||
/// Most category rows a PivotChart plots.
|
||||
const MAX_CHART_ROWS: usize = 10_000;
|
||||
|
||||
/// Most series a PivotChart gets: the chart renderer reads at most 255 series, and each one owns
|
||||
/// reference text as long as its category rows, copied into every linked chart.
|
||||
const MAX_CHART_SERIES: usize = 255;
|
||||
|
||||
/// A PivotChart's series for a report written at `origin`: categories are the innermost row
|
||||
/// items, one series per item column (column items × value fields); totals are left out.
|
||||
pub fn chart_series(sheet: &str, origin: CellRef, out: &Output) -> Vec<Series> {
|
||||
@@ -64,6 +68,7 @@ pub fn chart_series(sheet: &str, origin: CellRef, out: &Output) -> Vec<Series> {
|
||||
let categories = out.label_col.and_then(|c| rows_ref(sheet, origin, c, &rows));
|
||||
out.chart_cols
|
||||
.iter()
|
||||
.take(MAX_CHART_SERIES)
|
||||
.filter_map(|(c, name)| {
|
||||
Some(Series {
|
||||
name: Some(format!("\"{}\"", name.replace('"', "\"\""))),
|
||||
@@ -106,3 +111,22 @@ pub fn clear_output(wb: &mut Workbook, sheet: usize, idx: usize) {
|
||||
p.last_range = None;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_pivot_chart_gets_at_most_the_series_the_renderer_reads() {
|
||||
let out = Output {
|
||||
grid: vec![],
|
||||
row_kinds: vec![],
|
||||
body_offset: 0,
|
||||
width: 1000,
|
||||
chart_rows: vec![1, 2, 3],
|
||||
chart_cols: (1..1000).map(|c| (c, format!("S{c}"))).collect(),
|
||||
label_col: Some(0),
|
||||
};
|
||||
assert_eq!(chart_series("Sheet1", CellRef::new(0, 0), &out).len(), MAX_CHART_SERIES);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::items::{Acc, FieldItems, build_tree, collect_siblings, is_collapsed, item_label};
|
||||
use super::output::{AggMap, CellKind, ColKind, ColSpec, Ctx, Grid, Line, LineKind, Output, RowKind, ValSrc, data_cols_exceed_sheet, flatten_cols};
|
||||
use super::output::{
|
||||
AggMap, CellKind, ColKind, ColSpec, Ctx, Grid, Line, LineKind, Output, RowKind, ValSrc, caption, data_cols_exceed_sheet, flatten_cols,
|
||||
};
|
||||
use super::{
|
||||
CalcSet, CellError, CellRef, HashMap, HashSet, MAX_AXIS_FIELDS, MAX_COLS, MAX_VALUE_FIELDS, PivotDateGroup, PivotField, PivotFilter, PivotFunc,
|
||||
PivotLayout, PivotShowAs, PivotTable, PivotValue, Source, Value, Workbook, read_source,
|
||||
@@ -94,6 +96,10 @@ struct AggregateData {
|
||||
col_leaves: HashSet<Vec<u32>>,
|
||||
}
|
||||
|
||||
/// Most accumulators (groups times value columns) one report may hold: twice what the 5 million
|
||||
/// group limit allowed at one value field.
|
||||
const MAX_ACCUMULATORS: usize = 16_000_000;
|
||||
|
||||
fn aggregate_record(
|
||||
input: &AggregateInput<'_>,
|
||||
data: &mut AggregateData,
|
||||
@@ -152,7 +158,9 @@ fn aggregate_record(
|
||||
if !input.cols.is_empty() && data_cols_exceed_sheet(data.col_leaves.len(), input.pt.grand_totals_rows, input.vals.len()) {
|
||||
return Err("The PivotTable report will not fit on the sheet.".into());
|
||||
}
|
||||
if data.aggs.len() > 5_000_000 {
|
||||
// Each entry holds one accumulator per value field and calculated-field source column, so the
|
||||
// width counts too: a handful of groups over hundreds of fields is large as well.
|
||||
if data.aggs.len() > 5_000_000 || data.aggs.len().saturating_mul(input.vals.len() + input.extra.len()) > MAX_ACCUMULATORS {
|
||||
return Err("The PivotTable report is too large.".into());
|
||||
}
|
||||
Ok(())
|
||||
@@ -259,8 +267,8 @@ fn write_headers(g: &mut Grid, input: HeaderInput<'_>) -> Result<(), String> {
|
||||
let lv = spec.path.len().saturating_sub(1);
|
||||
let label = spec.path.last().and_then(|it| col_items.get(lv).map(|fi| fi.label(*it))).unwrap_or_default();
|
||||
let text = match vi {
|
||||
Some(vi) if k > 1 => format!("{label} {}", val_name(*vi)),
|
||||
_ => format!("{label} Total"),
|
||||
Some(vi) if k > 1 => caption(&label, &val_name(*vi)),
|
||||
_ => caption(&label, "Total"),
|
||||
};
|
||||
g.set(first_level + lv, c, Value::text(text), CellKind::ColLabel, 0, None);
|
||||
}
|
||||
@@ -318,6 +326,7 @@ struct ShowContext<'a> {
|
||||
value_index: usize,
|
||||
level0: &'a [u32],
|
||||
siblings: &'a HashMap<Vec<u32>, Vec<u32>>,
|
||||
cache: &'a ShowCache,
|
||||
}
|
||||
|
||||
fn number(value: &Option<Value>) -> Option<f64> {
|
||||
@@ -334,42 +343,89 @@ fn ratio(a: Value, b: Option<Value>) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// What Running Total and Rank need from other cells, computed once per sibling set instead of
|
||||
/// once per displayed value (which rescanned the whole set every time).
|
||||
type ShowKey = (Vec<u32>, Vec<u32>, usize);
|
||||
/// Cumulative values by top-level item, and the total of all of them.
|
||||
type RunningTotals = (HashMap<u32, f64>, f64);
|
||||
/// Top-level items by (rest of a row path, column path).
|
||||
type ItemsByRest = HashMap<(Vec<u32>, Vec<u32>), Vec<u32>>;
|
||||
|
||||
#[derive(Default)]
|
||||
struct ShowCache {
|
||||
/// Ranked values' sibling numbers, sorted ascending without repeats, by (parent, column, value).
|
||||
ranks: std::cell::RefCell<HashMap<ShowKey, Vec<f64>>>,
|
||||
/// Running totals by top-level item, with the total of all of them, by (rest of path, column, value).
|
||||
running: std::cell::RefCell<HashMap<ShowKey, RunningTotals>>,
|
||||
/// The top-level items that have a row path ending in `rest` (with column path `col`), in
|
||||
/// top-level order, by (`rest`, `col`): built once from the report's cells, so a running total
|
||||
/// visits the items that can hold a number, not every top-level item per distinct `rest`.
|
||||
items_by_rest: std::cell::RefCell<Option<ItemsByRest>>,
|
||||
}
|
||||
|
||||
fn running_total(raw: Value, lookup: &ValueLookup<'_>, context: &ShowContext<'_>) -> Value {
|
||||
let Some((first, _)) = context.row_path.split_first() else { return raw };
|
||||
let Some((first, rest)) = context.row_path.split_first() else { return raw };
|
||||
if !matches!(raw, Value::Number(_)) {
|
||||
return raw;
|
||||
}
|
||||
let mut total = 0.0;
|
||||
for item in context.level0 {
|
||||
let mut path = context.row_path.to_vec();
|
||||
if let Some(first_item) = path.first_mut() {
|
||||
*first_item = *item;
|
||||
let mut index = context.cache.items_by_rest.borrow_mut();
|
||||
let items_by_rest = index.get_or_insert_with(|| {
|
||||
let position: HashMap<u32, usize> = context.level0.iter().enumerate().rev().map(|(at, item)| (*item, at)).collect();
|
||||
let mut map = ItemsByRest::new();
|
||||
for (row, col) in lookup.aggs.keys() {
|
||||
if let Some((item, rest)) = row.split_first()
|
||||
&& position.contains_key(item)
|
||||
{
|
||||
map.entry((rest.to_vec(), col.clone())).or_default().push(*item);
|
||||
}
|
||||
}
|
||||
total += number(&lookup.get(&path, context.col_path, context.value_index)).unwrap_or(0.0);
|
||||
if item == first {
|
||||
break;
|
||||
for items in map.values_mut() {
|
||||
items.sort_by_key(|item| position.get(item).copied().unwrap_or(usize::MAX));
|
||||
}
|
||||
}
|
||||
Value::number(total)
|
||||
map
|
||||
});
|
||||
let mut cache = context.cache.running.borrow_mut();
|
||||
let (by_item, all) = cache.entry((rest.to_vec(), context.col_path.to_vec(), context.value_index)).or_insert_with(|| {
|
||||
let mut total = 0.0;
|
||||
let mut by_item = HashMap::new();
|
||||
for item in items_by_rest.get(&(rest.to_vec(), context.col_path.to_vec())).into_iter().flatten() {
|
||||
let mut path = Vec::with_capacity(rest.len() + 1);
|
||||
path.push(*item);
|
||||
path.extend_from_slice(rest);
|
||||
// An item with no number here adds nothing and is not stored (the shown cell always has one).
|
||||
let Some(value) = number(&lookup.get(&path, context.col_path, context.value_index)) else { continue };
|
||||
total += value;
|
||||
by_item.entry(*item).or_insert(total);
|
||||
}
|
||||
(by_item, total)
|
||||
});
|
||||
Value::number(by_item.get(first).copied().unwrap_or(*all))
|
||||
}
|
||||
|
||||
fn ranked_value(raw: Value, lookup: &ValueLookup<'_>, context: &ShowContext<'_>) -> Value {
|
||||
let Some((_, parent)) = context.row_path.split_last() else { return Value::Empty };
|
||||
let Some(me) = raw.as_f64() else { return raw };
|
||||
let siblings = context.siblings.get(parent).cloned().unwrap_or_default();
|
||||
let mut cache = context.cache.ranks.borrow_mut();
|
||||
let sorted = cache.entry((parent.to_vec(), context.col_path.to_vec(), context.value_index)).or_insert_with(|| {
|
||||
let mut all: Vec<f64> = context
|
||||
.siblings
|
||||
.get(parent)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|sibling| {
|
||||
let mut path = parent.to_vec();
|
||||
path.push(*sibling);
|
||||
number(&lookup.get(&path, context.col_path, context.value_index))
|
||||
})
|
||||
.collect();
|
||||
all.sort_by(f64::total_cmp);
|
||||
all.dedup();
|
||||
all
|
||||
});
|
||||
// Excel's ranks are dense: ties share a rank and the next value takes the next one
|
||||
// (5, 4, 3, 3, 4 rank 1, 2, 3, 3, 2), so count the distinct larger values.
|
||||
let mut larger: Vec<f64> = siblings
|
||||
.into_iter()
|
||||
.filter_map(|sibling| {
|
||||
let mut path = parent.to_vec();
|
||||
path.push(sibling);
|
||||
number(&lookup.get(&path, context.col_path, context.value_index)).filter(|value| *value > me)
|
||||
})
|
||||
.collect();
|
||||
larger.sort_by(f64::total_cmp);
|
||||
larger.dedup();
|
||||
Value::number(larger.len() as f64 + 1.0)
|
||||
let larger = sorted.len() - sorted.partition_point(|value| *value <= me);
|
||||
Value::number(larger as f64 + 1.0)
|
||||
}
|
||||
|
||||
fn show_value(definition: &PivotValue, raw: Value, lookup: &ValueLookup<'_>, context: &ShowContext<'_>) -> Value {
|
||||
@@ -642,6 +698,7 @@ pub(super) fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut pro
|
||||
})
|
||||
.collect();
|
||||
let lookup = ValueLookup { wb, source: src, calc: &calc, vals: &vals, extra: &extra, aggs: &aggs, value_count: k };
|
||||
let show_cache = ShowCache::default();
|
||||
let mut chart_rows: Vec<u32> = Vec::new();
|
||||
let chart_cols: Vec<(u32, String)> = data_cols
|
||||
.iter()
|
||||
@@ -699,7 +756,7 @@ pub(super) fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut pro
|
||||
let Some((_, vdef)) = vals.get(*vi) else { continue };
|
||||
let cp = spec.path.as_slice();
|
||||
let Some(raw) = lookup.get(rp, cp, *vi) else { continue };
|
||||
let context = ShowContext { row_path: rp, col_path: cp, value_index: *vi, level0: &level0, siblings: &siblings };
|
||||
let context = ShowContext { row_path: rp, col_path: cp, value_index: *vi, level0: &level0, siblings: &siblings, cache: &show_cache };
|
||||
let shown = show_value(vdef, raw, &lookup, &context);
|
||||
if let Some(pr) = probe.as_deref_mut() {
|
||||
if pr.at == Some((r, l + j)) {
|
||||
|
||||
@@ -21,7 +21,7 @@ pub(super) struct FieldItems {
|
||||
}
|
||||
|
||||
fn cmp_items(a: &Item, b: &Item) -> Ordering {
|
||||
a.class.cmp(&b.class).then(a.num.total_cmp(&b.num)).then_with(|| a.text.cmp(&b.text))
|
||||
a.class.cmp(&b.class).then(a.num.total_cmp(&b.num)).then_with(|| gridcraft_core::compare_text(&a.text, &b.text))
|
||||
}
|
||||
|
||||
/// (identity key, item) for a source value under a field's date grouping.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user